diff --git a/docs/_layouts/default.html b/docs/_layouts/default.html
index 7d98500ecf32..99f4b22b473c 100644
--- a/docs/_layouts/default.html
+++ b/docs/_layouts/default.html
@@ -12,6 +12,8 @@
+
+
@@ -64,10 +66,13 @@
{% if page.url contains "/articles/" %}
-
- {{ page.name | remove: '.md' | split: "-" | join: " " }}
-
-
+
{{ content }}
diff --git a/docs/_sass/_main.scss b/docs/_sass/_main.scss
index cfdf4ff3a2bc..ea18acef7c23 100644
--- a/docs/_sass/_main.scss
+++ b/docs/_sass/_main.scss
@@ -350,10 +350,14 @@ button {
h1 {
&.title {
font-size: 2.25em;
+ flex: 1;
}
}
.article {
+ .hidden {
+ display: none;
+ }
img {
display: block;
margin: 20px auto;
@@ -458,6 +462,69 @@ button {
opacity: 0.8;
}
}
+
+ .selector-container {
+ background-color: $color-highlightBG;
+ display: flex;
+ flex-direction: row-reverse;
+ gap: 20px;
+ border-radius: 12px;
+ padding: 20px;
+ margin-bottom: 20px;
+ justify-content: space-between;
+ * > ol, ul {
+ padding: 0;
+ }
+
+ @include maxBreakpoint($breakpoint-tablet) {
+ flex-direction: column;
+ }
+ }
+
+ select {
+ height: 28px;
+ border-radius: 20px;
+ padding: 0px 26px 0px 12px;
+ color: $color-text;
+ font-size: 11px;
+ font-weight: 700;
+ text-align: center;
+ cursor: pointer;
+
+ @include maxBreakpoint($breakpoint-tablet) {
+ width: 100px;
+ }
+
+ }
+
+ select {
+ background: url("/assets/images/down.svg") no-repeat right $color-button-background;
+ background-size: 12px;
+ background-position-x: 85%;
+ appearance: none !important;
+ -moz-appearance: none !important;
+ -webkit-appearance: none !important;
+ }
+
+ .info {
+ padding: 12px;
+ border-radius: 8px;
+ background-color: $color-highlightBG;
+ color: $color-text;
+ display: flex;
+ gap: 12px;
+ align-items: center;
+
+ img {
+ height: 16px;
+ width: 16px;
+ }
+
+ * {
+ padding: 0;
+ margin: 0;
+ }
+ }
}
}
@@ -842,3 +909,45 @@ button {
}
}
}
+
+.title-platform-tabs {
+ display: flex;
+ justify-content: space-between;
+ padding-bottom: 12px;
+ h1 {
+ padding: 0;
+ }
+
+ @include maxBreakpoint($breakpoint-tablet) {
+ flex-direction: column;
+ gap: 20px;
+ }
+}
+
+#platform-tabs {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ text-align: center;
+ font-weight: 700;
+ font-size: 13px;
+ gap: 4px;
+}
+
+#platform-tabs > * {
+ cursor: pointer;
+ border-radius: 20px;
+ padding: 10px 20px;
+ box-sizing: border-box;
+ height: 36px;
+ line-height: 16px;
+}
+
+#platform-tabs > .active {
+ color: $color-text;
+ background-color: $color-button-background;
+}
+
+.hidden {
+ display: none;
+}
diff --git a/docs/articles/expensify-classic/getting-started/Join-your-company's-workspace.md b/docs/articles/expensify-classic/getting-started/Join-your-company's-workspace.md
new file mode 100644
index 000000000000..099f381e6010
--- /dev/null
+++ b/docs/articles/expensify-classic/getting-started/Join-your-company's-workspace.md
@@ -0,0 +1,258 @@
+---
+title: Join your company's workspace
+description: Get started with Expensify as an employee or other company member
+---
+
+
+# Overview
+
+Welcome to Expensify! If you received an invitation to join your company’s Expensify workspace, follow the steps below to get started.
+
+# 1. Download the mobile app
+
+Upload your expenses and check your reports right from your phone by downloading the Expensify mobile app. You can search for “Expensify” in the app store, or tap one of the links below.
+
+[iOS](https://apps.apple.com/us/app/expensify-expense-tracker/id471713959)
+| [Android](https://play.google.com/store/apps/details?id=org.me.mobiexpensifyg&hl=en_US&gl=US)
+
+# 2. Add your name and photo
+
+{% include selector.html values="desktop, mobile" %}
+{% include option.html value="desktop" %}
+
+ Click the profile image at the top of the main menu.
+ Hover over the profile picture and click Change .
+ Update your profile picture and name.
+
+ Name: Enter your first and last name into the fields and click Update . Note that this name will be visible to anyone in your company workspace.
+ Photo: Click Add Photo .
+
+
+
+
+{% include end-option.html %}
+
+{% include option.html value="mobile" %}
+
+
+ Tap the ☰ menu icon in the top left.
+ Tap the profile picture icon.
+ Tap the Edit icon next to your name and update your name or photo.
+
+ Name: Enter your first and/or last name into the fields and tap Update . Note that this name will be visible to anyone in your company workspace.
+ Photo: Tap Upload Photo and either:
+
+ Tap the capture button to take a new photo.
+ Tap the photo icon on the left to select a saved photo.
+
+
+
+
+
+{% include end-option.html %}
+{% include end-selector.html %}
+
+
+# 3. Meet Concierge
+Your personal assistant, Concierge, lives on your Expensify Home page on both desktop and the mobile app.
+
+Concierge will walk you through setting up your account and also provide:
+
+ Reminders to do things like submit your expenses
+ Alerts when more information is needed on an expense report
+ Updates on new and improved account features
+
+
+You can also get support at any time by clicking the green chat bubble in the right corner. This will open a chat with Concierge where you can ask questions and receive direct support.
+
+# 4. Learn how to add an expense
+As an employee, you may need to document reimbursable expenses (like business travel paid for with personal funds) or non-reimbursable expenses (like a lunch paid for with a company card). You can create an expense automatically by SmartScanning a receipt, or you can enter them manually.
+
+## SmartScan a receipt
+
+You can upload pictures of your receipts to Expensify and SmartScan will automatically capture the receipt details including the merchant, date, total, and currency.
+
+{% include selector.html values="desktop, mobile" %}
+{% include option.html value="desktop" %}
+
+ Click the Expenses tab.
+ Click the + icon in the top right and select Scan Receipt .
+ Upload a saved image of a receipt.
+
+
+{% include end-option.html %}
+
+{% include option.html value="mobile" %}
+
+ Open the mobile app and tap the camera icon in the bottom right corner.
+ Upload or take a photo of your receipt.
+
+ Upload a photo: Click the photo icon in the left corner and select the image from your device.
+ Take a photo: Click the camera icon in the right corner to select the mode, make sure all of the transaction details are clearly visible, and then take the photo.
+
+ Normal Mode: Upload one receipt.
+ Rapid Fire Mode: Upload multiple receipts at once.
+
+{% include end-option.html %}
+{% include end-selector.html %}
+
+You can open any receipt and select **Fill out details myself** to add or edit the merchant, date, total, description, category, or add attendees who took part in the expense. You can also check that the expense is correctly labeled as reimbursable or non-reimbursable, and split the expense if multiple expenses are included on one receipt.
+
+*Note: You can also email receipts to SmartScan by sending them to receipts@expensify.com from an email address tied to your Expensify account (either a primary or secondary email). SmartScan will automatically pull all of the details from the receipt, fill them in for you, and add the receipt to the Expenses tab on your account.*
+
+## Manually enter an expense
+
+{% include selector.html values="desktop, mobile" %}
+
+{% include option.html value="desktop" %}
+
+ Click the Expenses tab.
+ Click the + icon in the top right.
+ Select the type of expense and enter the expense details.
+
+ Manually create: Manually enter receipt details.
+ Scan receipt: Upload a saved image of a receipt.
+ Create multiple: Upload expenses in bulk.
+ Time: Create an expense based on hours.
+ Distance: Create an expense based on distance.
+
+ Manually Create: Manually enter the distance details for the expense.
+ Create from Map: Enter the start and end destination and Expensify will help you create a receipt for the trip.
+
+
+ Click Save .
+
+{% include end-option.html %}
+
+{% include option.html value="mobile" %}
+
+ Tap the ☰ menu icon in the top left.
+ Tap Expenses .
+ Tap the + icon in the top right.
+ Tap the correct expense type and enter the expense details.
+
+ Manually create: Manually enter receipt details.
+ Time: Enter work time and rate.
+ Manually create (Distance): Manually enter trip details by total distance.
+ Odometer: Manually enter trip details by start and end odometer readings.
+ Start GPS: Track distance while using the Expensify app to automatically calculate the distance in real time during the trip.
+
+ Tap Save .
+
+{% include end-option.html %}
+
+{% include end-selector.html %}
+
+# 5. Learn how to create & submit an expense report
+
+Once you’ve created your expenses, they may be automatically added to an expense report if your company has this feature enabled. If not, your next step will be to add your expenses to a report and submit them for payment.
+
+{% include selector.html values="Desktop, Mobile" %}
+
+{% include option.html value="desktop" %}
+
+
+ Click the Reports tab.
+
+ If a report has been automatically created for your most recently submitted expense, then you don’t have to do anything else—your report is already created and will also be automatically submitted.
+ If a report has not been automatically created, follow the steps below.
+
+ Click New Report , or click the New Report dropdown and select Expense Report .
+ Click Add Expenses .
+ Click an expense to add it to the report.
+
+ If an expense you already added does not appear in the list, use the filter on the left to search by the merchant name or change the date range. Note: Only expenses that are not already on a report will appear.
+
+ Once all your expenses are added to the report, click the X to close the pop-up.
+ (Optional) Make any desired changes to the report and/or expenses.
+
+ Click the Edit icon next to the report name to change it. If this icon is not visible, the option has been disabled by your workspace.
+ Click the X icon next to an expense to remove it from the report.
+ Click the Expense Details icon to review or edit the expense details.
+ At the bottom of the report, add comments to include more information.
+ Click the Attachments icon to add additional attachments.
+
+ When the report is ready to send for approval, click Submit .
+ Enter the details for who will receive a notification email about your report and what they will receive.
+
+ To: Enter the name(s) who will be approving your report (if they are not already listed).
+ CC: Enter the email address of anyone else who should be notified that your expense report has been submitted. Add a comma between each email address if adding more than one.
+ Memo: Enter any relevant notes.
+ Attach PDF: Select this checkbox to attach a copy of your report to the email.
+
+ Click Send .
+
+
+{% include end-option.html %}
+
+{% include option.html value="mobile" %}
+
+ Tap the ☰ menu icon in the top left.
+ Tap Reports .
+
+ If a report has been automatically created for your most recently submitted expense, then you don’t have to do anything else—your report is already created and will also be automatically submitted.
+ If a report has not been automatically created, follow the steps below.
+
+ Tap the + icon and tap Expense Report .
+ Tap Add Expenses , then tap an expense to add it to the report. Repeat this step until all desired expenses are added. Note: Only expenses that are not already on a report will appear.
+ (Optional) Make any desired changes to the report and/or expenses.
+
+ Tap the report name to change it.
+ Tap an expense to review or edit the expense details.
+ At the bottom of the report, add comments to include more information.
+ Tap the Attachments icon to add additional attachments.
+
+ When the report is ready to send for approval, tap Submit Report .
+ Add any additional sending details and tap Submit .
+ Enter the details for who will receive a notification email about your report and what they will receive.
+
+ To: Enter the name(s) who will be approving your report (if they are not already listed).
+ CC: Enter the email address of anyone else who should be notified that your expense report has been submitted. Add a comma between each email address if adding more than one.
+ Memo: Enter any relevant notes.
+ Attach PDF: Select this checkbox to attach a copy of your report to the email.
+
+ Tap Submit .
+
+
+{% include end-option.html %}
+
+{% include end-selector.html %}
+
+# 6. Add a secondary login
+
+Connect your personal email address as a secondary login so you always have access to your Expensify account, even if your employer changes.
+
+*Note: This process is currently not available from the mobile app and must be completed from the Expensify website.*
+
+
+ Hover over Settings, then click Account .
+ Under the Account Details tab, scroll down to the Secondary Logins section and click Add Secondary Login .
+ Enter the email address or phone number you wish to use as a secondary login. For phone numbers, be sure to include the international code, if applicable.
+ Find the email or text message from Expensify containing the Magic Code and enter it into the field to add the secondary login.
+
+
+# 7. Secure your account
+
+Add an extra layer of security to help keep your financial data safe and secure by enabling two-factor authentication. This will require you to enter a code generated by your preferred authenticator app (like Google Authenticator or Microsoft Authenticator) when you log in.
+
+*Note: This process is currently not available from the mobile app and must be completed from the Expensify website.*
+
+
+ Hover over Settings, then click Account .
+ Under the Account Details tab, scroll down to the Two Factor Authentication section and enable the toggle.
+ Save a copy of your backup codes. This step is critical—You will lose access to your account if you cannot use your authenticator app and do not have your recovery codes.
+
+ Click Download to save a copy of your backup codes to your computer.
+ Click Copy to paste the codes into a document or other secure location.
+
+ Click Continue .
+ Download or open your authenticator app and either:
+
+ Scan the QR code shown on your computer screen.
+ Enter the 6-digit code from your authenticator app into Expensify and click Verify .
+
+
+
+When you log in to Expensify in the future, you’ll open your authenticator app to get the code and enter it into Expensify. A new code regenerates every few seconds, so the code is always different. If the code time runs out, you can generate a new code as needed.
+
+
\ No newline at end of file
diff --git a/docs/articles/expensify-classic/getting-started/Security.md b/docs/articles/expensify-classic/getting-started/Security.md
deleted file mode 100644
index 5a0036e3e161..000000000000
--- a/docs/articles/expensify-classic/getting-started/Security.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Security
-description: Security
----
-## Resource Coming Soon!
diff --git a/docs/articles/expensify-classic/getting-started/Using-The-App.md b/docs/articles/expensify-classic/getting-started/Using-The-App.md
index 9b8bc530e12e..f1bc31793ba8 100644
--- a/docs/articles/expensify-classic/getting-started/Using-The-App.md
+++ b/docs/articles/expensify-classic/getting-started/Using-The-App.md
@@ -2,6 +2,8 @@
title: Using the app
description: Streamline expense management effortlessly with the Expensify mobile app. Learn how to install, enable push notifications, and use SmartScan to capture, categorize, and track expenses. Versatile for personal and business use, Expensify is a secure and automated solution for managing your finances on the go.
---
+
+
# Overview
The Expensify mobile app is the ultimate expense management solution that makes it effortless to track and submit your receipts and expenses. Use the app to snap a picture of your receipts, categorize and submit expenses, and even review and approve expense reports.
# How to install the Expensify app
@@ -9,7 +11,7 @@ To get started with Expensify on your mobile device, you need to download the ap
1. Visit the App Store (iOS) or Google Play Store (Android).
2. Search for "Expensify" and select the official Expensify app.
3. Tap "Download" or "Install."
-
+
Once the app is installed, open it and log in with your Expensify credentials. If you don't have an Expensify account, you can create one during the sign-up process.
# How to enable on push notifications
Push notifications keep you informed about expense approvals, reimbursements, and more. To enable push notifications:
@@ -54,3 +56,10 @@ Expensify takes security seriously and employs encryption and other security mea
Yes, you can use the mobile app offline to capture receipts and create expenses. The app will sync your data once you have an internet connection.
{% include faq-end.md %}
+
+
+
+# Coming soon
+
+
+
\ No newline at end of file
diff --git a/docs/assets/images/info.svg b/docs/assets/images/info.svg
new file mode 100644
index 000000000000..96924fbb6cf7
--- /dev/null
+++ b/docs/assets/images/info.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/docs/assets/js/main.js b/docs/assets/js/main.js
index 6e154bb09a44..6b3390148ff0 100644
--- a/docs/assets/js/main.js
+++ b/docs/assets/js/main.js
@@ -165,6 +165,37 @@ window.addEventListener('load', () => {
insertElementAfter(searchInput, searchLabel);
});
+const tocbotOptions = {
+ // Where to render the table of contents.
+ tocSelector: '.article-toc',
+
+ // Where to grab the headings to build the table of contents.
+ contentSelector: '',
+
+ // Disable the collapsible functionality of the library by
+ // setting the maximum number of heading levels (6)
+ collapseDepth: 6,
+ headingSelector: 'h1, h2, h3, summary',
+
+ // Main class to add to lists.
+ listClass: 'lhn-items',
+
+ // Main class to add to links.
+ linkClass: 'link',
+
+ // Class to add to active links,
+ // the link corresponding to the top most heading on the page.
+ activeLinkClass: 'selected-article',
+
+ // Headings offset between the headings and the top of the document (requires scrollSmooth enabled)
+ headingsOffset: 80,
+ scrollSmoothOffset: -80,
+ scrollSmooth: true,
+
+ // If there is a fixed article scroll container, set to calculate titles' offset
+ scrollContainer: 'content-area',
+};
+
window.addEventListener('DOMContentLoaded', () => {
injectFooterCopywrite();
@@ -179,38 +210,51 @@ window.addEventListener('DOMContentLoaded', () => {
buttonCloseSidebar.addEventListener('click', closeSidebar);
}
- if (window.tocbot) {
- window.tocbot.init({
- // Where to render the table of contents.
- tocSelector: '.article-toc',
+ const expensifyClassicTab = document.getElementById('platform-tab-expensify-classic');
+ const newExpensifyTab = document.getElementById('platform-tab-new-expensify');
- // Where to grab the headings to build the table of contents.
- contentSelector: '.article-toc-content',
+ const expensifyClassicContent = document.getElementById('expensify-classic');
+ const newExpensifyContent = document.getElementById('new-expensify');
- // Disable the collapsible functionality of the library by
- // setting the maximum number of heading levels (6)
- collapseDepth: 6,
- headingSelector: 'h1, h2, h3, summary',
+ let contentSelector = '.article-toc-content';
+ if (expensifyClassicContent) {
+ contentSelector = '#expensify-classic';
+ } else if (newExpensifyContent) {
+ contentSelector = '#new-expensify';
+ }
- // Main class to add to lists.
- listClass: 'lhn-items',
+ if (window.tocbot) {
+ window.tocbot.init({
+ ...tocbotOptions,
+ contentSelector,
+ });
+ }
- // Main class to add to links.
- linkClass: 'link',
+ // eslint-disable-next-line es/no-optional-chaining
+ expensifyClassicTab?.addEventListener('click', () => {
+ expensifyClassicTab.classList.add('active');
+ expensifyClassicContent.classList.remove('hidden');
- // Class to add to active links,
- // the link corresponding to the top most heading on the page.
- activeLinkClass: 'selected-article',
+ newExpensifyTab.classList.remove('active');
+ newExpensifyContent.classList.add('hidden');
+ window.tocbot.refresh({
+ ...tocbotOptions,
+ contentSelector: '#expensify-classic',
+ });
+ });
- // Headings offset between the headings and the top of the document (requires scrollSmooth enabled)
- headingsOffset: 80,
- scrollSmoothOffset: -80,
- scrollSmooth: true,
+ // eslint-disable-next-line es/no-optional-chaining
+ newExpensifyTab?.addEventListener('click', () => {
+ newExpensifyTab.classList.add('active');
+ newExpensifyContent.classList.remove('hidden');
- // If there is a fixed article scroll container, set to calculate titles' offset
- scrollContainer: 'content-area',
+ expensifyClassicTab.classList.remove('active');
+ expensifyClassicContent.classList.add('hidden');
+ window.tocbot.refresh({
+ ...tocbotOptions,
+ contentSelector: '#new-expensify',
});
- }
+ });
document.getElementById('header-button').addEventListener('click', toggleHeaderMenu);
diff --git a/docs/assets/js/platform-tabs.js b/docs/assets/js/platform-tabs.js
new file mode 100644
index 000000000000..e677e58b1e97
--- /dev/null
+++ b/docs/assets/js/platform-tabs.js
@@ -0,0 +1,22 @@
+const expensifyClassicContent = document.getElementById('expensify-classic');
+const newExpensifyContent = document.getElementById('new-expensify');
+const platformTabs = document.getElementById('platform-tabs');
+
+if (expensifyClassicContent) {
+ const tab = document.createElement('div');
+ tab.innerHTML = 'Expensify Classic';
+ tab.id = 'platform-tab-expensify-classic';
+ tab.classList.add('active');
+ platformTabs.appendChild(tab);
+}
+
+if (newExpensifyContent) {
+ const tab = document.createElement('div');
+ tab.innerHTML = 'New Expensify';
+ tab.id = 'platform-tab-new-expensify';
+
+ if (!expensifyClassicContent) {
+ tab.classList.add('active');
+ }
+ platformTabs.appendChild(tab);
+}
diff --git a/docs/assets/js/selector.js b/docs/assets/js/selector.js
new file mode 100644
index 000000000000..7373c7892767
--- /dev/null
+++ b/docs/assets/js/selector.js
@@ -0,0 +1,35 @@
+function syncSelectors(selectedIndex) {
+ const allSelects = document.querySelectorAll('select');
+ for (let i = 0; i < allSelects.length; i++) {
+ allSelects[i].selectedIndex = selectedIndex;
+ }
+}
+
+function selectOption(select) {
+ if (!select) {
+ return;
+ }
+
+ syncSelectors(select.selectedIndex);
+
+ const allOptions = Array.from(select.options);
+ const selectedValue = select.options[select.selectedIndex].value;
+
+ // Hide section that isn't selected, and show section that is selected.
+ allOptions.forEach((option) => {
+ if (option.value === selectedValue) {
+ const toShow = document.getElementsByClassName(option.value);
+ for (let i = 0; i < toShow.length; i++) {
+ toShow[i].classList.remove('hidden');
+ }
+ return;
+ }
+
+ const toHide = document.getElementsByClassName(option.value);
+ for (let i = 0; i < toHide.length; i++) {
+ toHide[i].classList.add('hidden');
+ }
+ });
+}
+
+window.onload = selectOption(document.getElementsByClassName('selector')[0]);
diff --git a/docs/redirects.csv b/docs/redirects.csv
index 2571cb1156eb..df615431533f 100644
--- a/docs/redirects.csv
+++ b/docs/redirects.csv
@@ -33,3 +33,19 @@ https://community.expensify.com/discussion/6794/how-to-change-your-email-in-expe
https://help.expensify.com/articles/expensify-classic/expensify-card/Expensify-Card-Perks.html,https://use.expensify.com/company-credit-card
https://help.expensify.com/articles/expensify-classic/expensify-partner-program/How-to-Join-the-ExpensifyApproved!-Partner-Program.html,https://use.expensify.com/accountants-program
https://help.expensify.com/articles/expensify-classic/getting-started/approved-accountants/Card-Revenue-Share-For-Expensify-Approved-Partners, https://use.expensify.com/blog/maximizing-rewards-expensifyapproved-accounting-partners-now-earn-0-5-revenue-share
+https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/International-Reimbursements,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/Global-Reimbursements
+https://community.expensify.com/discussion/4452/how-to-merge-accounts,https://help.expensify.com/articles/expensify-classic/account-settings/Merge-Accounts#gsc.tab=0
+https://community.expensify.com/discussion/4783/how-to-add-or-remove-a-copilot#latest,https://help.expensify.com/articles/expensify-classic/account-settings/Copilot#gsc.tab=0
+https://community.expensify.com/discussion/4343/expensify-anz-partnership-announcement,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Connect-ANZ
+https://community.expensify.com/discussion/7318/deep-dive-company-credit-card-import-options,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards
+https://community.expensify.com/discussion/2673/personalize-your-commercial-card-feed-name,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Commercial-Card-Feeds
+https://community.expensify.com/discussion/6569/how-to-import-and-assign-company-cards-from-csv-file,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/CSV-Import
+https://community.expensify.com/discussion/4714/how-to-set-up-a-direct-bank-connection-for-company-cards,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Direct-Bank-Connections
+https://community.expensify.com/discussion/4828/how-to-reconcile-your-company-cards-statement-in-expensify,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Reconciliation
+https://community.expensify.com/discussion/5366/deep-dive-troubleshooting-credit-card-issues-in-expensify,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Troubleshooting
+https://community.expensify.com/discussion/9554/how-to-set-up-global-reimbursemen,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/Global-Reimbursements
+https://community.expensify.com/discussion/4463/how-to-remove-or-manage-settings-for-imported-personal-cards,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/Personal-Credit-Cards
+https://community.expensify.com/discussion/5793/how-to-connect-your-personal-card-to-import-expenses,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/Personal-Credit-Cards
+https://community.expensify.com/discussion/4826/how-to-set-your-annual-subscription-size,https://help.expensify.com/articles/expensify-classic/billing-and-subscriptions/Annual-Subscription
+https://community.expensify.com/discussion/5667/deep-dive-how-does-the-annual-subscription-billing-work,https://help.expensify.com/articles/expensify-classic/billing-and-subscriptions/Annual-Subscription
+https://help.expensify.com/expensify-classic/hubs/getting-started/plan-types,https://www.expensify.com/pricing
diff --git a/ios/NewExpensify.xcodeproj/project.pbxproj b/ios/NewExpensify.xcodeproj/project.pbxproj
index 41f53a0b8f7d..5d3bf1c07985 100644
--- a/ios/NewExpensify.xcodeproj/project.pbxproj
+++ b/ios/NewExpensify.xcodeproj/project.pbxproj
@@ -615,6 +615,7 @@
"${PODS_CONFIGURATION_BUILD_DIR}/Airship/AirshipPreferenceCenterResources.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/GoogleSignIn/GoogleSignIn.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/React-Core/RCTI18nStrings.bundle",
+ "${PODS_ROOT}/../../node_modules/@expensify/react-native-live-markdown/parser/react-native-live-markdown-parser.js",
);
name = "[CP] Copy Pods Resources";
outputPaths = (
@@ -625,6 +626,7 @@
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/AirshipPreferenceCenterResources.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleSignIn.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RCTI18nStrings.bundle",
+ "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/react-native-live-markdown-parser.js",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
@@ -784,6 +786,7 @@
"${PODS_CONFIGURATION_BUILD_DIR}/Airship/AirshipPreferenceCenterResources.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/GoogleSignIn/GoogleSignIn.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/React-Core/RCTI18nStrings.bundle",
+ "${PODS_ROOT}/../../node_modules/@expensify/react-native-live-markdown/parser/react-native-live-markdown-parser.js",
);
name = "[CP] Copy Pods Resources";
outputPaths = (
@@ -794,6 +797,7 @@
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/AirshipPreferenceCenterResources.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleSignIn.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RCTI18nStrings.bundle",
+ "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/react-native-live-markdown-parser.js",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist
index 341edd0c9dfe..6a6a54e58c49 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -19,7 +19,7 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
-
1.4.35
+
1.4.36
CFBundleSignature
????
CFBundleURLTypes
@@ -40,7 +40,7 @@
CFBundleVersion
-
1.4.35.0
+
1.4.36.0
ITSAppUsesNonExemptEncryption
LSApplicationQueriesSchemes
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index 6b39db8b2f27..763896fd45f1 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -15,10 +15,10 @@
CFBundlePackageType
BNDL
CFBundleShortVersionString
-
1.4.35
+
1.4.36
CFBundleSignature
????
CFBundleVersion
-
1.4.35.0
+
1.4.36.0
diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist
index ff9b56d72408..a4bef0847c95 100644
--- a/ios/NotificationServiceExtension/Info.plist
+++ b/ios/NotificationServiceExtension/Info.plist
@@ -11,9 +11,9 @@
CFBundleName
$(PRODUCT_NAME)
CFBundleShortVersionString
-
1.4.35
+
1.4.36
CFBundleVersion
-
1.4.35.0
+
1.4.36.0
NSExtension
NSExtensionPointIdentifier
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index c3a0a534d9b4..584dd5636773 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -1178,6 +1178,10 @@ PODS:
- React-Core
- react-native-launch-arguments (4.0.2):
- React
+ - react-native-live-markdown (0.1.0):
+ - glog
+ - RCT-Folly (= 2022.05.16.00)
+ - React-Core
- react-native-netinfo (11.2.1):
- React-Core
- react-native-pager-view (6.2.2):
@@ -1525,6 +1529,7 @@ DEPENDENCIES:
- react-native-image-picker (from `../node_modules/react-native-image-picker`)
- react-native-key-command (from `../node_modules/react-native-key-command`)
- react-native-launch-arguments (from `../node_modules/react-native-launch-arguments`)
+ - "react-native-live-markdown (from `../node_modules/@expensify/react-native-live-markdown`)"
- "react-native-netinfo (from `../node_modules/@react-native-community/netinfo`)"
- react-native-pager-view (from `../node_modules/react-native-pager-view`)
- react-native-pdf (from `../node_modules/react-native-pdf`)
@@ -1720,6 +1725,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native-key-command"
react-native-launch-arguments:
:path: "../node_modules/react-native-launch-arguments"
+ react-native-live-markdown:
+ :path: "../node_modules/@expensify/react-native-live-markdown"
react-native-netinfo:
:path: "../node_modules/@react-native-community/netinfo"
react-native-pager-view:
@@ -1915,6 +1922,7 @@ SPEC CHECKSUMS:
react-native-image-picker: c33d4e79f0a14a2b66e5065e14946ae63749660b
react-native-key-command: 5af6ee30ff4932f78da6a2109017549042932aa5
react-native-launch-arguments: 5f41e0abf88a15e3c5309b8875d6fd5ac43df49d
+ react-native-live-markdown: 1ca4275d4dba8eebb736a005148f24a8224f54d9
react-native-netinfo: 8a7fd3f7130ef4ad2fb4276d5c9f8d3f28d2df3d
react-native-pager-view: 02a5c4962530f7efc10dd51ee9cdabeff5e6c631
react-native-pdf: b4ca3d37a9a86d9165287741c8b2ef4d8940c00e
diff --git a/package-lock.json b/package-lock.json
index acadf891d8d7..674325508da5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,16 +1,17 @@
{
"name": "new.expensify",
- "version": "1.4.35-0",
+ "version": "1.4.36-0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "1.4.35-0",
+ "version": "1.4.36-0",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"@dotlottie/react-player": "^1.6.3",
+ "@expensify/react-native-live-markdown": "git+ssh://git@github.com/Expensify/react-native-live-markdown.git#c316611781f19815caebfed5540e0faf2a274785",
"@expo/metro-runtime": "~3.1.1",
"@formatjs/intl-datetimeformat": "^6.10.0",
"@formatjs/intl-getcanonicallocales": "^2.2.0",
@@ -3349,6 +3350,22 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/@expensify/react-native-live-markdown": {
+ "version": "0.1.0",
+ "resolved": "git+ssh://git@github.com/Expensify/react-native-live-markdown.git#c316611781f19815caebfed5540e0faf2a274785",
+ "integrity": "sha512-yF3oaBhqWQonl12LPELYLsgfmqCsGg2bu15g/h8XzVX3f/nzfPtrWE/ax2lWEIpIjk4/+aEu/VGNKLnlehjTxQ==",
+ "license": "MIT",
+ "workspaces": [
+ "example"
+ ],
+ "engines": {
+ "node": ">= 18.0.0"
+ },
+ "peerDependencies": {
+ "react": "*",
+ "react-native": "*"
+ }
+ },
"node_modules/@expo/bunyan": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@expo/bunyan/-/bunyan-4.0.0.tgz",
@@ -44854,8 +44871,6 @@
},
"node_modules/react-native-flipper": {
"version": "0.159.0",
- "resolved": "https://gitpkg.now.sh/facebook/flipper/react-native/react-native-flipper?9cacc9b59402550eae866e0e81e5f0c2f8203e6b",
- "integrity": "sha512-M784S/qPuN/HqjdvXg98HIDmfm0sF8mACc56YNg87nzEF90zKSKp0XyOE83SEW+UJX2Gq/rf9BvM2GZeXlrhnQ==",
"dev": true,
"license": "MIT",
"peer": true,
diff --git a/package.json b/package.json
index bdbdb27dbf59..0c1105f978c6 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "1.4.35-0",
+ "version": "1.4.36-0",
"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.",
@@ -59,6 +59,7 @@
},
"dependencies": {
"@dotlottie/react-player": "^1.6.3",
+ "@expensify/react-native-live-markdown": "git+ssh://git@github.com/Expensify/react-native-live-markdown.git#c316611781f19815caebfed5540e0faf2a274785",
"@expo/metro-runtime": "~3.1.1",
"@formatjs/intl-datetimeformat": "^6.10.0",
"@formatjs/intl-getcanonicallocales": "^2.2.0",
diff --git a/patches/@react-navigation+stack+6.3.16+002+dontDetachScreen.patch b/patches/@react-navigation+stack+6.3.16+002+dontDetachScreen.patch
index d64fc4fecf74..877521094cd4 100644
--- a/patches/@react-navigation+stack+6.3.16+002+dontDetachScreen.patch
+++ b/patches/@react-navigation+stack+6.3.16+002+dontDetachScreen.patch
@@ -43,7 +43,7 @@ index 7558eb3..b7bb75e 100644
}) : STATE_TRANSITIONING_OR_BELOW_TOP;
}
+
-+ const isHomeScreenAndNotOnTop = route.name === 'Home' && isScreenActive !== STATE_ON_TOP;
++ const isHomeScreenAndNotOnTop = (route.name === 'BottomTabNavigator' || route.name === 'Settings_Root') && isScreenActive !== STATE_ON_TOP;
+
const {
headerShown = true,
diff --git a/patches/react-native-web+0.19.9+005+image-header-support.patch b/patches/react-native-web+0.19.9+005+image-header-support.patch
deleted file mode 100644
index 4652e22662f0..000000000000
--- a/patches/react-native-web+0.19.9+005+image-header-support.patch
+++ /dev/null
@@ -1,200 +0,0 @@
-diff --git a/node_modules/react-native-web/dist/exports/Image/index.js b/node_modules/react-native-web/dist/exports/Image/index.js
-index 95355d5..19109fc 100644
---- a/node_modules/react-native-web/dist/exports/Image/index.js
-+++ b/node_modules/react-native-web/dist/exports/Image/index.js
-@@ -135,7 +135,22 @@ function resolveAssetUri(source) {
- }
- return uri;
- }
--var Image = /*#__PURE__*/React.forwardRef((props, ref) => {
-+function raiseOnErrorEvent(uri, _ref) {
-+ var onError = _ref.onError,
-+ onLoadEnd = _ref.onLoadEnd;
-+ if (onError) {
-+ onError({
-+ nativeEvent: {
-+ error: "Failed to load resource " + uri + " (404)"
-+ }
-+ });
-+ }
-+ if (onLoadEnd) onLoadEnd();
-+}
-+function hasSourceDiff(a, b) {
-+ return a.uri !== b.uri || JSON.stringify(a.headers) !== JSON.stringify(b.headers);
-+}
-+var BaseImage = /*#__PURE__*/React.forwardRef((props, ref) => {
- var ariaLabel = props['aria-label'],
- blurRadius = props.blurRadius,
- defaultSource = props.defaultSource,
-@@ -236,16 +251,10 @@ var Image = /*#__PURE__*/React.forwardRef((props, ref) => {
- }
- }, function error() {
- updateState(ERRORED);
-- if (onError) {
-- onError({
-- nativeEvent: {
-- error: "Failed to load resource " + uri + " (404)"
-- }
-- });
-- }
-- if (onLoadEnd) {
-- onLoadEnd();
-- }
-+ raiseOnErrorEvent(uri, {
-+ onError,
-+ onLoadEnd
-+ });
- });
- }
- function abortPendingRequest() {
-@@ -277,10 +286,78 @@ var Image = /*#__PURE__*/React.forwardRef((props, ref) => {
- suppressHydrationWarning: true
- }), hiddenImage, createTintColorSVG(tintColor, filterRef.current));
- });
--Image.displayName = 'Image';
-+BaseImage.displayName = 'Image';
-+
-+/**
-+ * This component handles specifically loading an image source with headers
-+ * default source is never loaded using headers
-+ */
-+var ImageWithHeaders = /*#__PURE__*/React.forwardRef((props, ref) => {
-+ // $FlowIgnore: This component would only be rendered when `source` matches `ImageSource`
-+ var nextSource = props.source;
-+ var _React$useState3 = React.useState(''),
-+ blobUri = _React$useState3[0],
-+ setBlobUri = _React$useState3[1];
-+ var request = React.useRef({
-+ cancel: () => {},
-+ source: {
-+ uri: '',
-+ headers: {}
-+ },
-+ promise: Promise.resolve('')
-+ });
-+ var onError = props.onError,
-+ onLoadStart = props.onLoadStart,
-+ onLoadEnd = props.onLoadEnd;
-+ React.useEffect(() => {
-+ if (!hasSourceDiff(nextSource, request.current.source)) {
-+ return;
-+ }
-+
-+ // When source changes we want to clean up any old/running requests
-+ request.current.cancel();
-+ if (onLoadStart) {
-+ onLoadStart();
-+ }
-+
-+ // Store a ref for the current load request so we know what's the last loaded source,
-+ // and so we can cancel it if a different source is passed through props
-+ request.current = ImageLoader.loadWithHeaders(nextSource);
-+ request.current.promise.then(uri => setBlobUri(uri)).catch(() => raiseOnErrorEvent(request.current.source.uri, {
-+ onError,
-+ onLoadEnd
-+ }));
-+ }, [nextSource, onLoadStart, onError, onLoadEnd]);
-+
-+ // Cancel any request on unmount
-+ React.useEffect(() => request.current.cancel, []);
-+ var propsToPass = _objectSpread(_objectSpread({}, props), {}, {
-+ // `onLoadStart` is called from the current component
-+ // We skip passing it down to prevent BaseImage raising it a 2nd time
-+ onLoadStart: undefined,
-+ // Until the current component resolves the request (using headers)
-+ // we skip forwarding the source so the base component doesn't attempt
-+ // to load the original source
-+ source: blobUri ? _objectSpread(_objectSpread({}, nextSource), {}, {
-+ uri: blobUri
-+ }) : undefined
-+ });
-+ return /*#__PURE__*/React.createElement(BaseImage, _extends({
-+ ref: ref
-+ }, propsToPass));
-+});
-
- // $FlowIgnore: This is the correct type, but casting makes it unhappy since the variables aren't defined yet
--var ImageWithStatics = Image;
-+var ImageWithStatics = /*#__PURE__*/React.forwardRef((props, ref) => {
-+ if (props.source && props.source.headers) {
-+ return /*#__PURE__*/React.createElement(ImageWithHeaders, _extends({
-+ ref: ref
-+ }, props));
-+ }
-+ return /*#__PURE__*/React.createElement(BaseImage, _extends({
-+ ref: ref
-+ }, props));
-+});
- ImageWithStatics.getSize = function (uri, success, failure) {
- ImageLoader.getSize(uri, success, failure);
- };
-diff --git a/node_modules/react-native-web/dist/modules/ImageLoader/index.js b/node_modules/react-native-web/dist/modules/ImageLoader/index.js
-index bc06a87..e309394 100644
---- a/node_modules/react-native-web/dist/modules/ImageLoader/index.js
-+++ b/node_modules/react-native-web/dist/modules/ImageLoader/index.js
-@@ -76,7 +76,7 @@ var ImageLoader = {
- var image = requests["" + requestId];
- if (image) {
- var naturalHeight = image.naturalHeight,
-- naturalWidth = image.naturalWidth;
-+ naturalWidth = image.naturalWidth;
- if (naturalHeight && naturalWidth) {
- success(naturalWidth, naturalHeight);
- complete = true;
-@@ -102,11 +102,19 @@ var ImageLoader = {
- id += 1;
- var image = new window.Image();
- image.onerror = onError;
-- image.onload = e => {
-+ image.onload = nativeEvent => {
- // avoid blocking the main thread
-- var onDecode = () => onLoad({
-- nativeEvent: e
-- });
-+ var onDecode = () => {
-+ // Append `source` to match RN's ImageLoadEvent interface
-+ nativeEvent.source = {
-+ uri: image.src,
-+ width: image.naturalWidth,
-+ height: image.naturalHeight
-+ };
-+ onLoad({
-+ nativeEvent
-+ });
-+ };
- if (typeof image.decode === 'function') {
- // Safari currently throws exceptions when decoding svgs.
- // We want to catch that error and allow the load handler
-@@ -120,6 +128,32 @@ var ImageLoader = {
- requests["" + id] = image;
- return id;
- },
-+ loadWithHeaders(source) {
-+ var uri;
-+ var abortController = new AbortController();
-+ var request = new Request(source.uri, {
-+ headers: source.headers,
-+ signal: abortController.signal
-+ });
-+ request.headers.append('accept', 'image/*');
-+ var promise = fetch(request).then(response => response.blob()).then(blob => {
-+ uri = URL.createObjectURL(blob);
-+ return uri;
-+ }).catch(error => {
-+ if (error.name === 'AbortError') {
-+ return '';
-+ }
-+ throw error;
-+ });
-+ return {
-+ promise,
-+ source,
-+ cancel: () => {
-+ abortController.abort();
-+ URL.revokeObjectURL(uri);
-+ }
-+ };
-+ },
- prefetch(uri) {
- return new Promise((resolve, reject) => {
- ImageLoader.load(uri, () => {
diff --git a/src/App.js b/src/App.js
index 3553900bbc7f..8045f4eb30ad 100644
--- a/src/App.js
+++ b/src/App.js
@@ -6,6 +6,7 @@ import Onyx from 'react-native-onyx';
import {PickerStateProvider} from 'react-native-picker-select';
import {SafeAreaProvider} from 'react-native-safe-area-context';
import '../wdyr';
+import ActiveWorkspaceContextProvider from './components/ActiveWorkspace/ActiveWorkspaceProvider';
import ColorSchemeWrapper from './components/ColorSchemeWrapper';
import ComposeProviders from './components/ComposeProviders';
import CustomStatusBarAndBackground from './components/CustomStatusBarAndBackground';
@@ -69,6 +70,7 @@ function App() {
PickerStateProvider,
EnvironmentProvider,
CustomStatusBarAndBackgroundContextProvider,
+ ActiveWorkspaceContextProvider,
]}
>
diff --git a/src/CONST.ts b/src/CONST.ts
index f434aa281866..b27923465a1f 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -46,6 +46,7 @@ const CONST = {
IN: 'in',
OUT: 'out',
},
+ BACKGROUND_IMAGE_TRANSITION_DURATION: 1000,
ARROW_HIDE_DELAY: 3000,
API_ATTACHMENT_VALIDATIONS: {
@@ -490,6 +491,8 @@ const CONST = {
// Use Environment.getEnvironmentURL to get the complete URL with port number
DEV_NEW_EXPENSIFY_URL: 'https://dev.new.expensify.com:',
OLDDOT_URLS: {
+ ADMIN_POLICIES_URL: 'admin_policies',
+ ADMIN_DOMAINS_URL: 'admin_domains',
INBOX: 'inbox',
},
@@ -1013,6 +1016,7 @@ const CONST = {
3: 100,
},
},
+ CENTRAL_PANE_ANIMATION_HEIGHT: 200,
LHN_SKELETON_VIEW_ITEM_HEIGHT: 64,
EXPENSIFY_PARTNER_NAME: 'expensify.com',
EMAIL: {
@@ -1331,6 +1335,7 @@ const CONST = {
REIMBURSEMENT_MANUAL: 'reimburseManual',
},
ID_FAKE: '_FAKE_',
+ EMPTY: 'EMPTY',
},
CUSTOM_UNITS: {
@@ -1481,6 +1486,10 @@ const CONST = {
OTHER_INVISIBLE_CHARACTERS: /[\u3164]/g,
REPORT_FIELD_TITLE: /{report:([a-zA-Z]+)}/g,
+
+ PATH_WITHOUT_POLICY_ID: /\/w\/[a-zA-Z0-9]+(\/|$)/,
+
+ POLICY_ID_FROM_PATH: /\/w\/([a-zA-Z0-9]+)(\/|$)/,
},
PRONOUNS: {
@@ -1490,7 +1499,7 @@ const CONST = {
GUIDES_CALL_TASK_IDS: {
CONCIERGE_DM: 'NewExpensifyConciergeDM',
WORKSPACE_INITIAL: 'WorkspaceHome',
- WORKSPACE_SETTINGS: 'WorkspaceGeneralSettings',
+ WORKSPACE_OVERVIEW: 'WorkspaceOverview',
WORKSPACE_CARD: 'WorkspaceCorporateCards',
WORKSPACE_REIMBURSE: 'WorkspaceReimburseReceipts',
WORKSPACE_BILLS: 'WorkspacePayBills',
@@ -3102,11 +3111,6 @@ const CONST = {
CAROUSEL: 3,
},
- BRICK_ROAD: {
- GBR: 'GBR',
- RBR: 'RBR',
- },
-
/**
* Constants for types of violations.
* Defined here because they need to be referenced by the type system to generate the
@@ -3165,6 +3169,12 @@ const CONST = {
MINI_CONTEXT_MENU_MAX_ITEMS: 4,
+ WORKSPACE_SWITCHER: {
+ NAME: 'Expensify',
+ SUBSCRIPT_ICON_SIZE: 8,
+ MINIMUM_WORKSPACES_TO_SHOW_SEARCH: 8,
+ },
+
REPORT_FIELD_TITLE_FIELD_ID: 'text_title',
} as const;
diff --git a/src/Expensify.js b/src/Expensify.js
index 12003968b284..dfb59a0f8848 100644
--- a/src/Expensify.js
+++ b/src/Expensify.js
@@ -62,7 +62,8 @@ const propTypes = {
/** Whether a new update is available and ready to install. */
updateAvailable: PropTypes.bool,
- /** Tells us if the sidebar has rendered */
+ /** Tells us if the sidebar has rendered - TODO: We don't use it as temporary solution to fix not hidding splashscreen */
+ // eslint-disable-next-line react/no-unused-prop-types
isSidebarLoaded: PropTypes.bool,
/** Information about a screen share call requested by a GuidesPlus agent */
@@ -83,6 +84,9 @@ const propTypes = {
/** Whether we should display the notification alerting the user that focus mode has been auto-enabled */
focusModeNotification: PropTypes.bool,
+ /** Last visited path in the app */
+ lastVisitedPath: PropTypes.string,
+
...withLocalizePropTypes,
};
@@ -97,6 +101,7 @@ const defaultProps = {
isCheckingPublicRoom: true,
updateRequired: false,
focusModeNotification: false,
+ lastVisitedPath: undefined,
};
const SplashScreenHiddenContext = React.createContext({});
@@ -107,6 +112,7 @@ function Expensify(props) {
const [isOnyxMigrated, setIsOnyxMigrated] = useState(false);
const [isSplashHidden, setIsSplashHidden] = useState(false);
const [hasAttemptedToOpenPublicRoom, setAttemptedToOpenPublicRoom] = useState(false);
+ const [initialUrl, setInitialUrl] = useState(null);
useEffect(() => {
if (props.isCheckingPublicRoom) {
@@ -125,7 +131,7 @@ function Expensify(props) {
[isSplashHidden],
);
- const shouldInit = isNavigationReady && (!isAuthenticated || props.isSidebarLoaded) && hasAttemptedToOpenPublicRoom;
+ const shouldInit = isNavigationReady && hasAttemptedToOpenPublicRoom;
const shouldHideSplash = shouldInit && !isSplashHidden;
const initializeClient = () => {
@@ -187,6 +193,7 @@ function Expensify(props) {
// If the app is opened from a deep link, get the reportID (if exists) from the deep link and navigate to the chat report
Linking.getInitialURL().then((url) => {
+ setInitialUrl(url);
Report.openReportFromDeepLink(url, isAuthenticated);
});
@@ -247,6 +254,8 @@ function Expensify(props) {
)}
@@ -286,6 +295,9 @@ export default compose(
key: ONYXKEYS.FOCUS_MODE_NOTIFICATION,
initWithStoredValues: false,
},
+ lastVisitedPath: {
+ key: ONYXKEYS.LAST_VISITED_PATH,
+ },
}),
)(Expensify);
diff --git a/src/NAVIGATORS.ts b/src/NAVIGATORS.ts
index c68a950d3501..3bc9c5e57b9b 100644
--- a/src/NAVIGATORS.ts
+++ b/src/NAVIGATORS.ts
@@ -4,6 +4,7 @@
* */
export default {
CENTRAL_PANE_NAVIGATOR: 'CentralPaneNavigator',
+ BOTTOM_TAB_NAVIGATOR: 'BottomTabNavigator',
LEFT_MODAL_NAVIGATOR: 'LeftModalNavigator',
RIGHT_MODAL_NAVIGATOR: 'RightModalNavigator',
FULL_SCREEN_NAVIGATOR: 'FullScreenNavigator',
diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts
index 2867cb3905a2..7328fb2543ad 100755
--- a/src/ONYXKEYS.ts
+++ b/src/ONYXKEYS.ts
@@ -243,6 +243,9 @@ const ONYXKEYS = {
// Max width supported for HTML element
MAX_CANVAS_WIDTH: 'maxCanvasWidth',
+ // Stores last visited path
+ LAST_VISITED_PATH: 'lastVisitedPath',
+
// Stores the recently used report fields
RECENTLY_USED_REPORT_FIELDS: 'recentlyUsedReportFields',
@@ -447,6 +450,7 @@ type OnyxValues = {
[ONYXKEYS.MAX_CANVAS_AREA]: number;
[ONYXKEYS.MAX_CANVAS_HEIGHT]: number;
[ONYXKEYS.MAX_CANVAS_WIDTH]: number;
+ [ONYXKEYS.LAST_VISITED_PATH]: string | undefined;
[ONYXKEYS.RECENTLY_USED_REPORT_FIELDS]: OnyxTypes.RecentlyUsedReportFields;
[ONYXKEYS.UPDATE_REQUIRED]: boolean;
@@ -454,7 +458,7 @@ type OnyxValues = {
[ONYXKEYS.COLLECTION.DOWNLOAD]: OnyxTypes.Download;
[ONYXKEYS.COLLECTION.POLICY]: OnyxTypes.Policy;
[ONYXKEYS.COLLECTION.POLICY_DRAFTS]: OnyxTypes.Policy;
- [ONYXKEYS.COLLECTION.POLICY_CATEGORIES]: OnyxTypes.PolicyCategory;
+ [ONYXKEYS.COLLECTION.POLICY_CATEGORIES]: OnyxTypes.PolicyCategories;
[ONYXKEYS.COLLECTION.POLICY_TAGS]: OnyxTypes.PolicyTags;
[ONYXKEYS.COLLECTION.POLICY_MEMBERS]: OnyxTypes.PolicyMembers;
[ONYXKEYS.COLLECTION.POLICY_MEMBERS_DRAFTS]: OnyxTypes.PolicyMember;
@@ -475,6 +479,7 @@ type OnyxValues = {
[ONYXKEYS.COLLECTION.SECURITY_GROUP]: OnyxTypes.SecurityGroup;
[ONYXKEYS.COLLECTION.TRANSACTION]: OnyxTypes.Transaction;
[ONYXKEYS.COLLECTION.TRANSACTION_DRAFT]: OnyxTypes.Transaction;
+ [ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS]: OnyxTypes.TransactionViolations;
[ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS]: OnyxTypes.RecentlyUsedTags;
[ONYXKEYS.COLLECTION.SELECTED_TAB]: string;
[ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS]: OnyxTypes.TransactionViolation[];
@@ -484,12 +489,12 @@ type OnyxValues = {
// Forms
[ONYXKEYS.FORMS.ADD_DEBIT_CARD_FORM]: OnyxTypes.AddDebitCardForm;
[ONYXKEYS.FORMS.ADD_DEBIT_CARD_FORM_DRAFT]: OnyxTypes.AddDebitCardForm;
- [ONYXKEYS.FORMS.WORKSPACE_SETTINGS_FORM]: OnyxTypes.Form;
- [ONYXKEYS.FORMS.WORKSPACE_SETTINGS_FORM_DRAFT]: OnyxTypes.Form;
+ [ONYXKEYS.FORMS.WORKSPACE_SETTINGS_FORM]: OnyxTypes.WorkspaceSettingsForm;
+ [ONYXKEYS.FORMS.WORKSPACE_SETTINGS_FORM_DRAFT]: OnyxTypes.WorkspaceSettingsForm;
[ONYXKEYS.FORMS.WORKSPACE_RATE_AND_UNIT_FORM]: OnyxTypes.Form;
[ONYXKEYS.FORMS.WORKSPACE_RATE_AND_UNIT_FORM_DRAFT]: OnyxTypes.Form;
- [ONYXKEYS.FORMS.CLOSE_ACCOUNT_FORM]: OnyxTypes.Form;
- [ONYXKEYS.FORMS.CLOSE_ACCOUNT_FORM_DRAFT]: OnyxTypes.Form;
+ [ONYXKEYS.FORMS.CLOSE_ACCOUNT_FORM]: OnyxTypes.CloseAccountForm;
+ [ONYXKEYS.FORMS.CLOSE_ACCOUNT_FORM_DRAFT]: OnyxTypes.CloseAccountForm;
[ONYXKEYS.FORMS.PROFILE_SETTINGS_FORM]: OnyxTypes.Form;
[ONYXKEYS.FORMS.PROFILE_SETTINGS_FORM_DRAFT]: OnyxTypes.Form;
[ONYXKEYS.FORMS.DISPLAY_NAME_FORM]: OnyxTypes.DisplayNameForm;
diff --git a/src/ROUTES.ts b/src/ROUTES.ts
index 9c4375b84ab6..a84dc9c8f9ae 100644
--- a/src/ROUTES.ts
+++ b/src/ROUTES.ts
@@ -12,7 +12,13 @@ function getUrlWithBackToParam(url: TUrl, backTo?: string):
}
const ROUTES = {
- HOME: '',
+ // If the user opens this route, we'll redirect them to the path saved in the last visited path or to the home page if the last visited path is empty.
+ ROOT: '',
+
+ // This route renders the list of reports.
+ HOME: 'home',
+
+ ALL_SETTINGS: 'all-settings',
// This is a utility route used to go to the user's concierge chat, or the sign-in page if the user's not authenticated
CONCIERGE: 'concierge',
@@ -59,7 +65,7 @@ const ROUTES = {
route: 'bank-account/:stepToOpen?',
getRoute: (stepToOpen = '', policyID = '', backTo?: string) => getUrlWithBackToParam(`bank-account/${stepToOpen}?policyID=${policyID}`, backTo),
},
-
+ WORKSPACE_SWITCHER: 'workspace-switcher',
SETTINGS: 'settings',
SETTINGS_PROFILE: 'settings/profile',
SETTINGS_SHARE_CODE: 'settings/shareCode',
@@ -439,9 +445,17 @@ const ROUTES = {
route: 'workspace/:policyID/invite-message',
getRoute: (policyID: string) => `workspace/${policyID}/invite-message` as const,
},
- WORKSPACE_SETTINGS: {
- route: 'workspace/:policyID/settings',
- getRoute: (policyID: string) => `workspace/${policyID}/settings` as const,
+ WORKSPACE_OVERVIEW: {
+ route: 'workspace/:policyID/overview',
+ getRoute: (policyID: string) => `workspace/${policyID}/overview` as const,
+ },
+ WORKSPACE_OVERVIEW_CURRENCY: {
+ route: 'workspace/:policyID/overview/currency',
+ getRoute: (policyID: string) => `workspace/${policyID}/overview/currency` as const,
+ },
+ WORKSPACE_OVERVIEW_NAME: {
+ route: 'workspace/:policyID/overview/name',
+ getRoute: (policyID: string) => `workspace/${policyID}/overview/name` as const,
},
WORKSPACE_AVATAR: {
route: 'workspace/:policyID/avatar',
diff --git a/src/SCREENS.ts b/src/SCREENS.ts
index 2bf40caede57..96b284dbea2f 100644
--- a/src/SCREENS.ts
+++ b/src/SCREENS.ts
@@ -12,6 +12,7 @@ const PROTECTED_SCREENS = {
const SCREENS = {
...PROTECTED_SCREENS,
+ ALL_SETTINGS: 'AllSettings',
REPORT: 'Report',
PROFILE_AVATAR: 'ProfileAvatar',
WORKSPACE_AVATAR: 'WorkspaceAvatar',
@@ -20,6 +21,7 @@ const SCREENS = {
TRANSITION_BETWEEN_APPS: 'TransitionBetweenApps',
VALIDATE_LOGIN: 'ValidateLogin',
UNLINK_LOGIN: 'UnlinkLogin',
+ SETTINGS_CENTRAL_PANE: 'SettingsCentralPane',
SETTINGS: {
ROOT: 'Settings_Root',
SHARE_CODE: 'Settings_Share_Code',
@@ -86,6 +88,10 @@ const SCREENS = {
},
LEFT_MODAL: {
SEARCH: 'Search',
+ WORKSPACE_SWITCHER: 'WorkspaceSwitcher',
+ },
+ WORKSPACE_SWITCHER: {
+ ROOT: 'WorkspaceSwitcher_Root',
},
RIGHT_MODAL: {
SETTINGS: 'Settings',
@@ -194,7 +200,7 @@ const SCREENS = {
WORKSPACE: {
INITIAL: 'Workspace_Initial',
- SETTINGS: 'Workspace_Settings',
+ OVERVIEW: 'Workspace_Overview',
CARD: 'Workspace_Card',
REIMBURSE: 'Workspace_Reimburse',
RATE_AND_UNIT: 'Workspace_RateAndUnit',
@@ -204,7 +210,8 @@ const SCREENS = {
MEMBERS: 'Workspace_Members',
INVITE: 'Workspace_Invite',
INVITE_MESSAGE: 'Workspace_Invite_Message',
- CURRENCY: 'Workspace_Settings_Currency',
+ CURRENCY: 'Workspace_Overview_Currency',
+ NAME: 'Workspace_Overview_Name',
},
EDIT_REQUEST: {
diff --git a/src/components/ActiveWorkspace/ActiveWorkspaceContext.tsx b/src/components/ActiveWorkspace/ActiveWorkspaceContext.tsx
new file mode 100644
index 000000000000..466f0f492c8e
--- /dev/null
+++ b/src/components/ActiveWorkspace/ActiveWorkspaceContext.tsx
@@ -0,0 +1,11 @@
+import {createContext} from 'react';
+
+type ActiveWorkspaceContextType = {
+ activeWorkspaceID?: string;
+ setActiveWorkspaceID: (activeWorkspaceID?: string) => void;
+};
+
+const ActiveWorkspaceContext = createContext({activeWorkspaceID: undefined, setActiveWorkspaceID: () => undefined});
+
+export default ActiveWorkspaceContext;
+export {type ActiveWorkspaceContextType};
diff --git a/src/components/ActiveWorkspace/ActiveWorkspaceProvider.tsx b/src/components/ActiveWorkspace/ActiveWorkspaceProvider.tsx
new file mode 100644
index 000000000000..884b9a2a2d95
--- /dev/null
+++ b/src/components/ActiveWorkspace/ActiveWorkspaceProvider.tsx
@@ -0,0 +1,19 @@
+import React, {useMemo, useState} from 'react';
+import type ChildrenProps from '@src/types/utils/ChildrenProps';
+import ActiveWorkspaceContext from './ActiveWorkspaceContext';
+
+function ActiveWorkspaceContextProvider({children}: ChildrenProps) {
+ const [activeWorkspaceID, setActiveWorkspaceID] = useState(undefined);
+
+ const value = useMemo(
+ () => ({
+ activeWorkspaceID,
+ setActiveWorkspaceID,
+ }),
+ [activeWorkspaceID],
+ );
+
+ return {children} ;
+}
+
+export default ActiveWorkspaceContextProvider;
diff --git a/src/components/AvatarCropModal/AvatarCropModal.tsx b/src/components/AvatarCropModal/AvatarCropModal.tsx
index 3ac2e3e3d729..dd6d41f4b227 100644
--- a/src/components/AvatarCropModal/AvatarCropModal.tsx
+++ b/src/components/AvatarCropModal/AvatarCropModal.tsx
@@ -341,6 +341,7 @@ function AvatarCropModal({imageUri = '', imageName = '', imageType = '', onClose
isVisible={isVisible}
type={CONST.MODAL.MODAL_TYPE.RIGHT_DOCKED}
onModalHide={resetState}
+ shouldUseCustomBackdrop
>
{},
onImageRemoved: () => {},
style: [],
+ disabledStyle: [],
DefaultAvatar: () => {},
isUsingDefaultAvatar: false,
size: CONST.AVATAR_SIZE.DEFAULT,
@@ -118,6 +121,7 @@ const defaultProps = {
headerTitle: '',
previewSource: '',
originalFileName: '',
+ disabled: false,
onViewPhotoPress: undefined,
anchorAlignment: {
horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT,
@@ -129,6 +133,7 @@ function AvatarWithImagePicker({
isFocused,
DefaultAvatar,
style,
+ disabledStyle,
pendingAction,
errors,
errorRowStyles,
@@ -142,14 +147,16 @@ function AvatarWithImagePicker({
originalFileName,
isUsingDefaultAvatar,
onImageRemoved,
- anchorPosition,
- anchorAlignment,
onImageSelected,
editorMaskImage,
+ avatarStyle,
+ disabled,
onViewPhotoPress,
}) {
const theme = useTheme();
const styles = useThemeStyles();
+ const {windowWidth} = useWindowDimensions();
+ const [popoverPosition, setPopoverPosition] = useState({horizontal: 0, vertical: 0});
const [isMenuVisible, setIsMenuVisible] = useState(false);
const [errorData, setErrorData] = useState({
validationError: null,
@@ -291,28 +298,50 @@ function AvatarWithImagePicker({
return menuItems;
};
+ useEffect(() => {
+ if (!anchorRef.current) {
+ return;
+ }
+
+ if (!isMenuVisible) {
+ return;
+ }
+
+ anchorRef.current.measureInWindow((x, y, width, height) => {
+ setPopoverPosition({
+ horizontal: x + (width - variables.photoUploadPopoverWidth) / 2,
+ vertical: y + height + variables.spacing2,
+ });
+ });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [isMenuVisible, windowWidth]);
+
return (
-
+
-
+
setIsMenuVisible((prev) => !prev)}
accessibilityRole={CONST.ACCESSIBILITY_ROLE.IMAGEBUTTON}
accessibilityLabel={translate('avatarWithImagePicker.editImage')}
- disabled={isAvatarCropModalOpen}
+ disabled={isAvatarCropModalOpen || disabled}
+ disabledStyle={disabledStyle}
ref={anchorRef}
>
{source ? (
)}
-
-
-
+ {!disabled && (
+
+
+
+ )}
@@ -376,10 +407,10 @@ function AvatarWithImagePicker({
}
}}
menuItems={menuItems}
- anchorPosition={anchorPosition}
+ anchorPosition={popoverPosition}
+ anchorAlignment={{horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP}}
withoutOverlay
anchorRef={anchorRef}
- anchorAlignment={anchorAlignment}
/>
);
}}
diff --git a/src/components/BlockingViews/ForceFullScreenView/index.native.tsx b/src/components/BlockingViews/ForceFullScreenView/index.native.tsx
new file mode 100644
index 000000000000..296e7c26a9bc
--- /dev/null
+++ b/src/components/BlockingViews/ForceFullScreenView/index.native.tsx
@@ -0,0 +1,9 @@
+import type ForceFullScreenViewProps from './types';
+
+function ForceFullScreenView({children}: ForceFullScreenViewProps) {
+ return children;
+}
+
+ForceFullScreenView.displayName = 'ForceFullScreenView';
+
+export default ForceFullScreenView;
diff --git a/src/components/BlockingViews/ForceFullScreenView/index.tsx b/src/components/BlockingViews/ForceFullScreenView/index.tsx
new file mode 100644
index 000000000000..8a02028168fa
--- /dev/null
+++ b/src/components/BlockingViews/ForceFullScreenView/index.tsx
@@ -0,0 +1,18 @@
+import React from 'react';
+import {View} from 'react-native';
+import useThemeStyles from '@hooks/useThemeStyles';
+import type ForceFullScreenViewProps from './types';
+
+function ForceFullScreenView({children, shouldForceFullScreen = false}: ForceFullScreenViewProps) {
+ const styles = useThemeStyles();
+
+ if (shouldForceFullScreen) {
+ return {children} ;
+ }
+
+ return children;
+}
+
+ForceFullScreenView.displayName = 'ForceFullScreenView';
+
+export default ForceFullScreenView;
diff --git a/src/components/BlockingViews/ForceFullScreenView/types.ts b/src/components/BlockingViews/ForceFullScreenView/types.ts
new file mode 100644
index 000000000000..b03e6d5900d7
--- /dev/null
+++ b/src/components/BlockingViews/ForceFullScreenView/types.ts
@@ -0,0 +1,7 @@
+import type ChildrenProps from '@src/types/utils/ChildrenProps';
+
+type ForceFullScreenViewProps = ChildrenProps & {
+ shouldForceFullScreen?: boolean;
+};
+
+export default ForceFullScreenViewProps;
diff --git a/src/components/BlockingViews/FullPageNotFoundView.tsx b/src/components/BlockingViews/FullPageNotFoundView.tsx
index 807029addf5e..8cabf7dce494 100644
--- a/src/components/BlockingViews/FullPageNotFoundView.tsx
+++ b/src/components/BlockingViews/FullPageNotFoundView.tsx
@@ -9,6 +9,7 @@ import variables from '@styles/variables';
import type {TranslationPaths} from '@src/languages/types';
import ROUTES from '@src/ROUTES';
import BlockingView from './BlockingView';
+import ForceFullScreenView from './ForceFullScreenView';
type FullPageNotFoundViewProps = {
/** Child elements */
@@ -37,6 +38,9 @@ type FullPageNotFoundViewProps = {
/** Function to call when pressing the navigation link */
onLinkPress?: () => void;
+
+ /** Whether we should force the full page view */
+ shouldForceFullScreen?: boolean;
};
// eslint-disable-next-line rulesdir/no-negated-variables
@@ -50,13 +54,14 @@ function FullPageNotFoundView({
shouldShowLink = true,
shouldShowBackButton = true,
onLinkPress = () => Navigation.dismissModal(),
+ shouldForceFullScreen = false,
}: FullPageNotFoundViewProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
if (shouldShow) {
return (
- <>
+
- >
+
);
}
diff --git a/src/components/Composer/index.native.tsx b/src/components/Composer/index.native.tsx
index c7b020a5c6dd..a3c037211d4c 100644
--- a/src/components/Composer/index.native.tsx
+++ b/src/components/Composer/index.native.tsx
@@ -1,9 +1,10 @@
+import {MarkdownTextInput} from '@expensify/react-native-live-markdown';
import type {ForwardedRef} from 'react';
import React, {useCallback, useEffect, useMemo, useRef} from 'react';
import type {TextInput} from 'react-native';
import {StyleSheet} from 'react-native';
import type {AnimatedTextInputRef} from '@components/RNTextInput';
-import RNTextInput from '@components/RNTextInput';
+import useMarkdownStyle from '@hooks/useMarkdownStyle';
import useResetComposerFocus from '@hooks/useResetComposerFocus';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
@@ -33,6 +34,7 @@ function Composer(
const textInput = useRef(null);
const {isFocused, shouldResetFocus} = useResetComposerFocus(textInput);
const theme = useTheme();
+ const markdownStyle = useMarkdownStyle();
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
@@ -66,7 +68,7 @@ function Composer(
const composerStyle = useMemo(() => StyleSheet.flatten(style), [style]);
return (
- (null);
const textInput = useRef(null);
const [numberOfLines, setNumberOfLines] = useState(numberOfLinesProp);
@@ -152,41 +150,6 @@ function Composer(
}
};
- /**
- * Set pasted text to clipboard
- */
- const paste = useCallback((text?: string) => {
- try {
- document.execCommand('insertText', false, text);
- // Pointer will go out of sight when a large paragraph is pasted on the web. Refocusing the input keeps the cursor in view.
- textInput.current?.blur();
- textInput.current?.focus();
- // eslint-disable-next-line no-empty
- } catch (e) {}
- }, []);
-
- /**
- * Manually place the pasted HTML into Composer
- */
- const handlePastedHTML = useCallback(
- (html: string) => {
- const parser = new ExpensiMark();
- paste(parser.htmlToMarkdown(html));
- },
- [paste],
- );
-
- /**
- * Paste the plaintext content into Composer.
- */
- const handlePastePlainText = useCallback(
- (event: ClipboardEvent) => {
- const plainText = event.clipboardData?.getData('text/plain');
- paste(plainText);
- },
- [paste],
- );
-
/**
* Check the paste event for an attachment, parse the data and call onPasteFile from props with the selected file,
* Otherwise, convert pasted HTML to Markdown and set it on the composer.
@@ -197,7 +160,7 @@ function Composer(
const isFocused = textInput.current?.isFocused();
if (!(isVisible || isFocused)) {
- return;
+ return true;
}
if (textInput.current !== event.target) {
@@ -207,7 +170,7 @@ function Composer(
// If it did originate in another input, we return early to prevent the composer from handling the paste
const isTargetInput = eventTarget?.nodeName === 'INPUT' || eventTarget?.nodeName === 'TEXTAREA' || eventTarget?.contentEditable === 'true';
if (isTargetInput) {
- return;
+ return true;
}
textInput.current?.focus();
@@ -218,13 +181,12 @@ function Composer(
const TEXT_HTML = 'text/html';
const clipboardDataHtml = event.clipboardData?.getData(TEXT_HTML) ?? '';
- const clipboardDataTypesHtml = event.clipboardData?.types.includes(TEXT_HTML) ?? false;
// If paste contains files, then trigger file management
if (event.clipboardData?.files.length && event.clipboardData.files.length > 0) {
// Prevent the default so we do not post the file name into the text box
- onPasteFile(event.clipboardData?.files[0]);
- return;
+ onPasteFile(event.clipboardData.files[0]);
+ return true;
}
// If paste contains base64 image
@@ -237,7 +199,7 @@ function Composer(
const src = embeddedImages[0].src;
const file = FileUtils.base64ToFile(src, 'image.png');
onPasteFile(file);
- return;
+ return true;
}
}
@@ -256,32 +218,13 @@ function Composer(
const file = new File([blob], 'image.jpg', {type: 'image/jpeg'});
onPasteFile(file);
});
- return;
+ return true;
}
}
}
-
- // If paste contains HTML
- if (clipboardDataTypesHtml) {
- const pastedHTML = clipboardDataHtml;
-
- const domparser = new DOMParser();
- const embeddedImages = domparser.parseFromString(pastedHTML, TEXT_HTML).images;
-
- // Exclude parsing img tags in the HTML, as fetching the image via fetch triggers a connect-src Content-Security-Policy error.
- if (embeddedImages.length > 0 && embeddedImages[0].src) {
- // If HTML has emoji, then treat this as plain text.
- if (embeddedImages[0].dataset && embeddedImages[0].dataset.stringifyType === 'emoji') {
- handlePastePlainText(event);
- return;
- }
- }
- handlePastedHTML(pastedHTML);
- return;
- }
- handlePastePlainText(event);
+ return false;
},
- [onPasteFile, handlePastedHTML, checkComposerVisibility, handlePastePlainText],
+ [onPasteFile, checkComposerVisibility],
);
/**
@@ -313,27 +256,18 @@ function Composer(
updateNumberOfLines();
}, [updateNumberOfLines]);
- useEffect(() => {
- // we need to handle listeners on navigation focus/blur as Composer is not unmounting
- // when navigating away to different report
- const unsubscribeFocus = navigation.addListener('focus', () => document.addEventListener('paste', handlePaste));
- const unsubscribeBlur = navigation.addListener('blur', () => document.removeEventListener('paste', handlePaste));
+ useHtmlPaste(textInput, handlePaste, true);
+ useEffect(() => {
if (typeof ref === 'function') {
ref(textInput.current);
}
- if (textInput.current) {
- document.addEventListener('paste', handlePaste);
- }
-
return () => {
- if (!isReportActionCompose) {
- ReportActionComposeFocusManager.clear();
+ if (isReportActionCompose) {
+ return;
}
- unsubscribeFocus();
- unsubscribeBlur();
- document.removeEventListener('paste', handlePaste);
+ ReportActionComposeFocusManager.clear();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
diff --git a/src/components/ContextMenuItem.tsx b/src/components/ContextMenuItem.tsx
index 4e9bd22e004c..d292f80d135e 100644
--- a/src/components/ContextMenuItem.tsx
+++ b/src/components/ContextMenuItem.tsx
@@ -38,6 +38,9 @@ type ContextMenuItemProps = {
/** Whether the menu item is focused or not */
isFocused?: boolean;
+
+ /** Whether the width should be limited */
+ shouldLimitWidth?: boolean;
};
type ContextMenuItemHandle = {
@@ -45,7 +48,7 @@ type ContextMenuItemHandle = {
};
function ContextMenuItem(
- {onPress, successIcon, successText = '', icon, text, isMini = false, description = '', isAnonymousAction = false, isFocused = false}: ContextMenuItemProps,
+ {onPress, successIcon, successText = '', icon, text, isMini = false, description = '', isAnonymousAction = false, isFocused = false, shouldLimitWidth = true}: ContextMenuItemProps,
ref: ForwardedRef,
) {
const styles = useThemeStyles();
@@ -94,7 +97,7 @@ function ContextMenuItem(
success={!isThrottledButtonActive}
description={description}
descriptionTextStyle={styles.breakWord}
- style={StyleUtils.getContextMenuItemStyles(windowWidth)}
+ style={shouldLimitWidth && StyleUtils.getContextMenuItemStyles(windowWidth)}
isAnonymousAction={isAnonymousAction}
focused={isFocused}
interactive={isThrottledButtonActive}
diff --git a/src/components/DotIndicatorMessage.tsx b/src/components/DotIndicatorMessage.tsx
index d18704fdfb05..d2143f5b48da 100644
--- a/src/components/DotIndicatorMessage.tsx
+++ b/src/components/DotIndicatorMessage.tsx
@@ -8,13 +8,12 @@ import useThemeStyles from '@hooks/useThemeStyles';
import fileDownload from '@libs/fileDownload';
import * as Localize from '@libs/Localize';
import CONST from '@src/CONST';
+import type {ReceiptError} from '@src/types/onyx/Transaction';
import Icon from './Icon';
import * as Expensicons from './Icon/Expensicons';
import {PressableWithoutFeedback} from './Pressable';
import Text from './Text';
-type ReceiptError = {error?: string; source: string; filename: string};
-
type DotIndicatorMessageProps = {
/**
* In most cases this should just be errors from onxyData
@@ -23,7 +22,7 @@ type DotIndicatorMessageProps = {
* timestamp: 'message',
* }
*/
- messages: Record;
+ messages: Record;
/** The type of message, 'error' shows a red dot, 'success' shows a green dot */
type: 'error' | 'success';
diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.js b/src/components/EmojiPicker/EmojiPickerMenu/index.js
index a723eed446a4..cbf55b31c74d 100755
--- a/src/components/EmojiPicker/EmojiPickerMenu/index.js
+++ b/src/components/EmojiPicker/EmojiPickerMenu/index.js
@@ -7,6 +7,7 @@ import _ from 'underscore';
import EmojiPickerMenuItem from '@components/EmojiPicker/EmojiPickerMenuItem';
import Text from '@components/Text';
import TextInput from '@components/TextInput';
+import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager';
import useLocalize from '@hooks/useLocalize';
import useSingleExecution from '@hooks/useSingleExecution';
import useStyleUtils from '@hooks/useStyleUtils';
@@ -52,6 +53,7 @@ function EmojiPickerMenu({forwardedRef, onEmojiSelected}) {
preferredSkinTone,
listStyle,
emojiListRef,
+ spacersIndexes,
} = useEmojiPickerMenu();
// Ref for the emoji search input
@@ -61,22 +63,11 @@ function EmojiPickerMenu({forwardedRef, onEmojiSelected}) {
// prevent auto focus when open picker for mobile device
const shouldFocusInputOnScreenFocus = canFocusInputOnScreenFocus();
- const [highlightedIndex, setHighlightedIndex] = useState(-1);
const [arePointerEventsDisabled, setArePointerEventsDisabled] = useState(false);
- const [selection, setSelection] = useState({start: 0, end: 0});
const [isFocused, setIsFocused] = useState(false);
const [isUsingKeyboardMovement, setIsUsingKeyboardMovement] = useState(false);
+ const [highlightEmoji, setHighlightEmoji] = useState(false);
const [highlightFirstEmoji, setHighlightFirstEmoji] = useState(false);
- const firstNonHeaderIndex = useMemo(() => _.findIndex(filteredEmojis, (item) => !item.spacer && !item.header), [filteredEmojis]);
-
- /**
- * On text input selection change
- *
- * @param {Event} event
- */
- const onSelectionChange = useCallback((event) => {
- setSelection(event.nativeEvent.selection);
- }, []);
const mouseMoveHandler = useCallback(() => {
if (!arePointerEventsDisabled) {
@@ -85,15 +76,39 @@ function EmojiPickerMenu({forwardedRef, onEmojiSelected}) {
setArePointerEventsDisabled(false);
}, [arePointerEventsDisabled]);
- /**
- * Focuses the search Input and has the text selected
- */
- function focusInputWithTextSelect() {
- if (!searchInputRef.current) {
- return;
- }
- searchInputRef.current.focus();
- }
+ const onFocusedIndexChange = useCallback(
+ (newIndex) => {
+ if (filteredEmojis.length === 0) {
+ return;
+ }
+
+ if (highlightFirstEmoji) {
+ setHighlightFirstEmoji(false);
+ }
+
+ if (!isUsingKeyboardMovement) {
+ setIsUsingKeyboardMovement(true);
+ }
+
+ // If the input is not focused and the new index is out of range, focus the input
+ if (newIndex < 0 && !searchInputRef.current.isFocused()) {
+ searchInputRef.current.focus();
+ }
+ },
+ [filteredEmojis.length, highlightFirstEmoji, isUsingKeyboardMovement],
+ );
+
+ const disabledIndexes = useMemo(() => (isListFiltered ? [] : [...headerIndices, ...spacersIndexes]), [headerIndices, isListFiltered, spacersIndexes]);
+
+ const [focusedIndex, setFocusedIndex] = useArrowKeyFocusManager({
+ maxIndex: filteredEmojis.length - 1,
+ // Spacers indexes need to be disabled so that the arrow keys don't focus them. All headers are hidden when list is filtered
+ disabledIndexes,
+ itemsPerRow: CONST.EMOJI_NUM_PER_ROW,
+ initialFocusedIndex: -1,
+ disableCyclicTraversal: true,
+ onFocusedIndexChange,
+ });
const filterEmojis = _.throttle((searchTerm) => {
const [normalizedSearchTerm, newFilteredEmojiList] = suggestEmojis(searchTerm);
@@ -105,119 +120,17 @@ function EmojiPickerMenu({forwardedRef, onEmojiSelected}) {
// There are no headers when searching, so we need to re-make them sticky when there is no search term
setFilteredEmojis(allEmojis);
setHeaderIndices(headerRowIndices);
- setHighlightedIndex(-1);
- setHighlightFirstEmoji(false);
+ setFocusedIndex(-1);
+ setHighlightEmoji(false);
return;
}
// Remove sticky header indices. There are no headers while searching and we don't want to make emojis sticky
setFilteredEmojis(newFilteredEmojiList);
setHeaderIndices([]);
- setHighlightedIndex(0);
setHighlightFirstEmoji(true);
+ setIsUsingKeyboardMovement(false);
}, throttleTime);
- /**
- * Highlights emojis adjacent to the currently highlighted emoji depending on the arrowKey
- * @param {String} arrowKey
- */
- const highlightAdjacentEmoji = useCallback(
- (arrowKey) => {
- if (filteredEmojis.length === 0) {
- return;
- }
-
- // Arrow Down and Arrow Right enable arrow navigation when search is focused
- if (searchInputRef.current && searchInputRef.current.isFocused()) {
- if (arrowKey !== 'ArrowDown' && arrowKey !== 'ArrowRight') {
- return;
- }
-
- if (arrowKey === 'ArrowRight' && !(searchInputRef.current.value.length === selection.start && selection.start === selection.end)) {
- return;
- }
-
- // Blur the input, change the highlight type to keyboard, and disable pointer events
- searchInputRef.current.blur();
- setArePointerEventsDisabled(true);
- setIsUsingKeyboardMovement(true);
- setHighlightFirstEmoji(false);
-
- // We only want to hightlight the Emoji if none was highlighted already
- // If we already have a highlighted Emoji, lets just skip the first navigation
- if (highlightedIndex !== -1) {
- return;
- }
- }
-
- // If nothing is highlighted and an arrow key is pressed
- // select the first emoji, apply keyboard movement styles, and disable pointer events
- if (highlightedIndex === -1) {
- setHighlightedIndex(firstNonHeaderIndex);
- setArePointerEventsDisabled(true);
- setIsUsingKeyboardMovement(true);
- return;
- }
-
- let newIndex = highlightedIndex;
- const move = (steps, boundsCheck, onBoundReached = () => {}) => {
- if (boundsCheck()) {
- onBoundReached();
- return;
- }
-
- // Move in the prescribed direction until we reach an element that isn't a header
- const isHeader = (e) => e.header || e.spacer;
- do {
- newIndex += steps;
- if (newIndex < 0) {
- break;
- }
- } while (isHeader(filteredEmojis[newIndex]));
- };
-
- switch (arrowKey) {
- case 'ArrowDown':
- move(CONST.EMOJI_NUM_PER_ROW, () => highlightedIndex + CONST.EMOJI_NUM_PER_ROW > filteredEmojis.length - 1);
- break;
- case 'ArrowLeft':
- move(
- -1,
- () => highlightedIndex - 1 < firstNonHeaderIndex,
- () => {
- // Reaching start of the list, arrow left set the focus to searchInput.
- focusInputWithTextSelect();
- newIndex = -1;
- },
- );
- break;
- case 'ArrowRight':
- move(1, () => highlightedIndex + 1 > filteredEmojis.length - 1);
- break;
- case 'ArrowUp':
- move(
- -CONST.EMOJI_NUM_PER_ROW,
- () => highlightedIndex - CONST.EMOJI_NUM_PER_ROW < firstNonHeaderIndex,
- () => {
- // Reaching start of the list, arrow up set the focus to searchInput.
- focusInputWithTextSelect();
- newIndex = -1;
- },
- );
- break;
- default:
- break;
- }
-
- // Actually highlight the new emoji, apply keyboard movement styles, and disable pointer events
- if (newIndex !== highlightedIndex) {
- setHighlightedIndex(newIndex);
- setArePointerEventsDisabled(true);
- setIsUsingKeyboardMovement(true);
- }
- },
- [filteredEmojis, firstNonHeaderIndex, highlightedIndex, selection.end, selection.start],
- );
-
const keyDownHandler = useCallback(
(keyBoardEvent) => {
if (keyBoardEvent.key.startsWith('Arrow')) {
@@ -225,14 +138,17 @@ function EmojiPickerMenu({forwardedRef, onEmojiSelected}) {
keyBoardEvent.preventDefault();
}
- // Move the highlight when arrow keys are pressed
- highlightAdjacentEmoji(keyBoardEvent.key);
return;
}
// Select the currently highlighted emoji if enter is pressed
- if (!isEnterWhileComposition(keyBoardEvent) && keyBoardEvent.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey && highlightedIndex !== -1) {
- const item = filteredEmojis[highlightedIndex];
+ if (!isEnterWhileComposition(keyBoardEvent) && keyBoardEvent.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey) {
+ let indexToSelect = focusedIndex;
+ if (highlightFirstEmoji) {
+ indexToSelect = 0;
+ }
+
+ const item = filteredEmojis[indexToSelect];
if (!item) {
return;
}
@@ -250,7 +166,6 @@ function EmojiPickerMenu({forwardedRef, onEmojiSelected}) {
// interfering with the input behaviour.
if (keyBoardEvent.key === 'Tab' || keyBoardEvent.key === 'Enter' || (keyBoardEvent.key === 'Shift' && searchInputRef.current && !searchInputRef.current.isFocused())) {
setIsUsingKeyboardMovement(true);
- return;
}
// We allow typing in the search box if any key is pressed apart from Arrow keys.
@@ -258,7 +173,7 @@ function EmojiPickerMenu({forwardedRef, onEmojiSelected}) {
searchInputRef.current.focus();
}
},
- [filteredEmojis, highlightAdjacentEmoji, highlightedIndex, isFocused, onEmojiSelected, preferredSkinTone],
+ [filteredEmojis, focusedIndex, highlightFirstEmoji, isFocused, onEmojiSelected, preferredSkinTone],
);
/**
@@ -343,13 +258,15 @@ function EmojiPickerMenu({forwardedRef, onEmojiSelected}) {
const emojiCode = types && types[preferredSkinTone] ? types[preferredSkinTone] : code;
- const isEmojiFocused = index === highlightedIndex && isUsingKeyboardMovement;
- const shouldEmojiBeHighlighted = index === highlightedIndex && highlightFirstEmoji;
+ const isEmojiFocused = index === focusedIndex && isUsingKeyboardMovement;
+ const shouldEmojiBeHighlighted = index === focusedIndex && highlightEmoji;
+ const shouldFirstEmojiBeHighlighted = index === 0 && highlightFirstEmoji;
return (
onEmojiSelected(emoji, item))}
onHoverIn={() => {
+ setHighlightEmoji(false);
setHighlightFirstEmoji(false);
if (!isUsingKeyboardMovement) {
return;
@@ -357,18 +274,26 @@ function EmojiPickerMenu({forwardedRef, onEmojiSelected}) {
setIsUsingKeyboardMovement(false);
}}
emoji={emojiCode}
- onFocus={() => setHighlightedIndex(index)}
- onBlur={() =>
- // Only clear the highlighted index if the highlighted index is the same,
- // meaning that the focus changed to an element that is not an emoji item.
- setHighlightedIndex((prevState) => (prevState === index ? -1 : prevState))
- }
+ onFocus={() => setFocusedIndex(index)}
isFocused={isEmojiFocused}
- isHighlighted={shouldEmojiBeHighlighted}
+ isHighlighted={shouldFirstEmojiBeHighlighted || shouldEmojiBeHighlighted}
/>
);
},
- [preferredSkinTone, highlightedIndex, isUsingKeyboardMovement, highlightFirstEmoji, singleExecution, translate, onEmojiSelected, isSmallScreenWidth, windowWidth, styles],
+ [
+ preferredSkinTone,
+ focusedIndex,
+ isUsingKeyboardMovement,
+ highlightEmoji,
+ highlightFirstEmoji,
+ singleExecution,
+ styles,
+ isSmallScreenWidth,
+ windowWidth,
+ translate,
+ onEmojiSelected,
+ setFocusedIndex,
+ ],
);
return (
@@ -389,9 +314,8 @@ function EmojiPickerMenu({forwardedRef, onEmojiSelected}) {
defaultValue=""
ref={searchInputRef}
autoFocus={shouldFocusInputOnScreenFocus}
- onSelectionChange={onSelectionChange}
onFocus={() => {
- setHighlightedIndex(-1);
+ setFocusedIndex(-1);
setIsFocused(true);
setIsUsingKeyboardMovement(false);
}}
@@ -413,7 +337,7 @@ function EmojiPickerMenu({forwardedRef, onEmojiSelected}) {
ref={emojiListRef}
data={filteredEmojis}
renderItem={renderItem}
- extraData={[highlightedIndex, preferredSkinTone]}
+ extraData={[focusedIndex, preferredSkinTone]}
stickyHeaderIndices={headerIndices}
/>
diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.native.js b/src/components/EmojiPicker/EmojiPickerMenu/index.native.js
index 1463ce736699..27a49d360906 100644
--- a/src/components/EmojiPicker/EmojiPickerMenu/index.native.js
+++ b/src/components/EmojiPicker/EmojiPickerMenu/index.native.js
@@ -21,6 +21,7 @@ function EmojiPickerMenu({onEmojiSelected}) {
const styles = useThemeStyles();
const {windowWidth, isSmallScreenWidth} = useWindowDimensions();
const {translate} = useLocalize();
+ const {singleExecution} = useSingleExecution();
const {
allEmojis,
headerEmojis,
@@ -35,7 +36,6 @@ function EmojiPickerMenu({onEmojiSelected}) {
listStyle,
emojiListRef,
} = useEmojiPickerMenu();
- const {singleExecution} = useSingleExecution();
const StyleUtils = useStyleUtils();
/**
@@ -73,7 +73,7 @@ function EmojiPickerMenu({onEmojiSelected}) {
/**
* Given an emoji item object, render a component based on its type.
* Items with the code "SPACER" return nothing and are used to fill rows up to 8
- * so that the sticky headers function properly
+ * so that the sticky headers function properly.
*
* @param {Object} item
* @returns {*}
diff --git a/src/components/EmojiPicker/EmojiPickerMenu/useEmojiPickerMenu.js b/src/components/EmojiPicker/EmojiPickerMenu/useEmojiPickerMenu.js
index 2d895193ec68..7caab5e6fb55 100644
--- a/src/components/EmojiPicker/EmojiPickerMenu/useEmojiPickerMenu.js
+++ b/src/components/EmojiPicker/EmojiPickerMenu/useEmojiPickerMenu.js
@@ -16,6 +16,7 @@ const useEmojiPickerMenu = () => {
const allEmojis = useMemo(() => EmojiUtils.mergeEmojisWithFrequentlyUsedEmojis(emojis), [frequentlyUsedEmojis]);
const headerEmojis = useMemo(() => EmojiUtils.getHeaderEmojis(allEmojis), [allEmojis]);
const headerRowIndices = useMemo(() => _.map(headerEmojis, (headerEmoji) => headerEmoji.index), [headerEmojis]);
+ const spacersIndexes = useMemo(() => EmojiUtils.getSpacersIndexes(allEmojis), [allEmojis]);
const [filteredEmojis, setFilteredEmojis] = useState(allEmojis);
const [headerIndices, setHeaderIndices] = useState(headerRowIndices);
const isListFiltered = allEmojis.length !== filteredEmojis.length;
@@ -61,6 +62,7 @@ const useEmojiPickerMenu = () => {
preferredSkinTone,
listStyle,
emojiListRef,
+ spacersIndexes,
};
};
diff --git a/src/components/EnvironmentBadge.tsx b/src/components/EnvironmentBadge.tsx
index ceb4acf1b9ee..9a0854b815ef 100644
--- a/src/components/EnvironmentBadge.tsx
+++ b/src/components/EnvironmentBadge.tsx
@@ -32,6 +32,7 @@ function EnvironmentBadge() {
badgeStyles={[styles.alignSelfStart, styles.headerEnvBadge]}
textStyles={[styles.headerEnvBadgeText, {fontWeight: '700'}]}
environment={environment}
+ pressable
/>
);
}
diff --git a/src/components/FeatureList.js b/src/components/FeatureList.js
index 8e6a0d1f74d6..53d0d8f7c381 100644
--- a/src/components/FeatureList.js
+++ b/src/components/FeatureList.js
@@ -4,53 +4,105 @@ import {View} from 'react-native';
import _ from 'underscore';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
+import stylePropTypes from '@styles/stylePropTypes';
+import variables from '@styles/variables';
+import Button from './Button';
import MenuItem from './MenuItem';
import menuItemPropTypes from './menuItemPropTypes';
-import Text from './Text';
+import Section from './Section';
const propTypes = {
+ /** The text to display in the title of the section */
+ title: PropTypes.string.isRequired,
+
+ /** The text to display in the subtitle of the section */
+ subtitle: PropTypes.string,
+
+ /** Text of the call to action button */
+ ctaText: PropTypes.string,
+
+ /** Accessibility label for the call to action button */
+ ctaAccessibilityLabel: PropTypes.string,
+
+ /** Action to call on cta button press */
+ onCTAPress: PropTypes.func,
+
/** A list of menuItems representing the feature list. */
menuItems: PropTypes.arrayOf(PropTypes.shape({...menuItemPropTypes, translationKey: PropTypes.string})).isRequired,
- /** A headline translation key to show above the feature list. */
- headline: PropTypes.string.isRequired,
+ /** The illustration to display in the header. Can be a JSON object representing a Lottie animation. */
+ illustration: PropTypes.shape({
+ file: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
+ w: PropTypes.number.isRequired,
+ h: PropTypes.number.isRequired,
+ }),
+
+ /** The style passed to the illustration */
+ illustrationStyle: stylePropTypes,
- /** A description translation key to show below the headline and above the feature list. */
- description: PropTypes.string.isRequired,
+ /** The background color to apply in the upper half of the screen. */
+ illustrationBackgroundColor: PropTypes.string,
};
-function FeatureList({menuItems, headline, description}) {
+const defaultProps = {
+ ctaText: '',
+ ctaAccessibilityLabel: '',
+ subtitle: '',
+ onCTAPress: () => {},
+ illustration: null,
+ illustrationBackgroundColor: '',
+ illustrationStyle: [],
+};
+
+function FeatureList({title, subtitle, ctaText, ctaAccessibilityLabel, onCTAPress, menuItems, illustration, illustrationStyle, illustrationBackgroundColor}) {
const styles = useThemeStyles();
const {translate} = useLocalize();
+
return (
- <>
-
-
- {translate(headline)}
-
- {translate(description)}
-
- {_.map(menuItems, ({translationKey, icon}) => (
-
+
+
+ {_.map(menuItems, ({translationKey, icon}) => (
+
+
+
+ ))}
+
+
- ))}
- >
+
+
);
}
FeatureList.propTypes = propTypes;
+FeatureList.defaultProps = defaultProps;
FeatureList.displayName = 'FeatureList';
export default FeatureList;
diff --git a/src/components/FloatingActionButton.tsx b/src/components/FloatingActionButton.tsx
index 5f75bf535319..17da0cebbd5f 100644
--- a/src/components/FloatingActionButton.tsx
+++ b/src/components/FloatingActionButton.tsx
@@ -8,6 +8,7 @@ import useLocalize from '@hooks/useLocalize';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import variables from '@styles/variables';
+import {PressableWithoutFeedback} from './Pressable';
import PressableWithFeedback from './Pressable/PressableWithFeedback';
import Tooltip from './Tooltip/PopoverAnchorTooltip';
@@ -95,39 +96,46 @@ function FloatingActionButton({onPress, isActive, accessibilityLabel, role}: Flo
Platform.OS === 'web' ? undefined : adapter,
);
+ const toggleFabAction = (event: GestureResponderEvent | KeyboardEvent | undefined) => {
+ // Drop focus to avoid blue focus ring.
+ fabPressable.current?.blur();
+ onPress(event);
+ };
+
return (
-
- {
- fabPressable.current = el;
-
- if (buttonRef && 'current' in buttonRef) {
- buttonRef.current = el;
- }
- }}
- accessibilityLabel={accessibilityLabel}
- role={role}
- pressDimmingValue={1}
- onPress={(e) => {
- // Drop focus to avoid blue focus ring.
- fabPressable.current?.blur();
- onPress(e);
- }}
- onLongPress={() => {}}
- style={[styles.floatingActionButton, animatedStyle]}
- >
-
+
+ {
+ fabPressable.current = el;
+ if (buttonRef && 'current' in buttonRef) {
+ buttonRef.current = el;
+ }
+ }}
+ accessibilityLabel={accessibilityLabel}
+ role={role}
+ pressDimmingValue={1}
+ onPress={toggleFabAction}
+ onLongPress={() => {}}
+ style={[styles.floatingActionButton, animatedStyle]}
>
-
-
-
-
+
+
+
+
+
+
);
}
diff --git a/src/components/HeaderPageLayout.tsx b/src/components/HeaderPageLayout.tsx
index 304bb2ce49b1..47b52fa3fcb2 100644
--- a/src/components/HeaderPageLayout.tsx
+++ b/src/components/HeaderPageLayout.tsx
@@ -36,8 +36,10 @@ type HeaderPageLayoutProps = ChildrenProps &
/** Style to apply to the whole section container */
style?: StyleProp;
- };
+ /** Whether or not to show the offline indicator */
+ shouldShowOfflineIndicatorInWideScreen?: boolean;
+ };
function HeaderPageLayout({
backgroundColor,
children,
@@ -47,6 +49,7 @@ function HeaderPageLayout({
childrenContainerStyles,
style,
headerContent,
+ shouldShowOfflineIndicatorInWideScreen = false,
...rest
}: HeaderPageLayoutProps) {
const theme = useTheme();
@@ -70,6 +73,7 @@ function HeaderPageLayout({
includeSafeAreaPaddingBottom={false}
offlineIndicatorStyle={[appBGColor]}
testID={HeaderPageLayout.displayName}
+ shouldShowOfflineIndicatorInWideScreen={shouldShowOfflineIndicatorInWideScreen}
>
{({safeAreaPaddingBottomStyle}) => (
<>
diff --git a/src/components/HeaderWithBackButton/index.tsx b/src/components/HeaderWithBackButton/index.tsx
index ec9d118e66a5..078cb37c7e0d 100755
--- a/src/components/HeaderWithBackButton/index.tsx
+++ b/src/components/HeaderWithBackButton/index.tsx
@@ -70,7 +70,7 @@ function HeaderWithBackButton({
// Hover on some part of close icons will not work on Electron if dragArea is true
// https://github.com/Expensify/App/issues/29598
dataSet={{dragArea: false}}
- style={[styles.headerBar, shouldShowBorderBottom && styles.borderBottom, shouldShowBackButton && styles.pl0, shouldOverlay && StyleSheet.absoluteFillObject]}
+ style={[styles.headerBar, shouldShowBorderBottom && styles.borderBottom, shouldShowBackButton ? styles.pl0 : styles.pl5, shouldOverlay && StyleSheet.absoluteFillObject]}
>
{shouldShowBackButton && (
diff --git a/src/components/Icon/Expensicons.ts b/src/components/Icon/Expensicons.ts
index 364fb03a2055..a6e7cc2882d6 100644
--- a/src/components/Icon/Expensicons.ts
+++ b/src/components/Icon/Expensicons.ts
@@ -25,6 +25,7 @@ import Building from '@assets/images/building.svg';
import Calendar from '@assets/images/calendar.svg';
import Camera from '@assets/images/camera.svg';
import Car from '@assets/images/car.svg';
+import CardsAndDomains from '@assets/images/cards-and-domains.svg';
import Cash from '@assets/images/cash.svg';
import Chair from '@assets/images/chair.svg';
import ChatBubbleAdd from '@assets/images/chatbubble-add.svg';
@@ -53,6 +54,7 @@ import EReceiptIcon from '@assets/images/eReceiptIcon.svg';
import Exclamation from '@assets/images/exclamation.svg';
import Exit from '@assets/images/exit.svg';
import Expand from '@assets/images/expand.svg';
+import ExpensifyAppIcon from '@assets/images/expensify-app-icon.svg';
import ExpensifyCard from '@assets/images/expensify-card-icon.svg';
import ExpensifyFooterLogoVertical from '@assets/images/expensify-footer-logo-vertical.svg';
import ExpensifyFooterLogo from '@assets/images/expensify-footer-logo.svg';
@@ -69,6 +71,7 @@ import Globe from '@assets/images/globe.svg';
import Hashtag from '@assets/images/hashtag.svg';
import Heart from '@assets/images/heart.svg';
import History from '@assets/images/history.svg';
+import Home from '@assets/images/home.svg';
import Hourglass from '@assets/images/hourglass.svg';
import ImageCropCircleMask from '@assets/images/image-crop-circle-mask.svg';
import ImageCropSquareMask from '@assets/images/image-crop-square-mask.svg';
@@ -131,6 +134,7 @@ import User from '@assets/images/user.svg';
import Users from '@assets/images/users.svg';
import Wallet from '@assets/images/wallet.svg';
import Workspace from '@assets/images/workspace-default-avatar.svg';
+import Wrench from '@assets/images/wrench.svg';
import Zoom from '@assets/images/zoom.svg';
import LoungeAccess from './svgs/LoungeAccessIcon';
@@ -145,6 +149,7 @@ export {
ArrowRight,
ArrowRightLong,
ArrowsUpDown,
+ Wrench,
BackArrow,
Bank,
Bill,
@@ -157,6 +162,7 @@ export {
Calendar,
Camera,
Car,
+ CardsAndDomains,
Cash,
ChatBubble,
ChatBubbles,
@@ -185,6 +191,7 @@ export {
EmptyStateAttachReceipt,
Exclamation,
Exit,
+ ExpensifyAppIcon,
ExpensifyCard,
ExpensifyWordmark,
ExpensifyFooterLogo,
@@ -205,6 +212,7 @@ export {
Hashtag,
Heart,
History,
+ Home,
Hourglass,
ImageCropCircleMask,
ImageCropSquareMask,
diff --git a/src/components/Icon/index.tsx b/src/components/Icon/index.tsx
index 20f3fd4a8acb..d3214565ba46 100644
--- a/src/components/Icon/index.tsx
+++ b/src/components/Icon/index.tsx
@@ -104,4 +104,5 @@ function Icon({
Icon.displayName = 'Icon';
+export type {IconProps};
export default Icon;
diff --git a/src/components/IllustratedHeaderPageLayout.tsx b/src/components/IllustratedHeaderPageLayout.tsx
index 72ec0adf7672..c40a4e33e67a 100644
--- a/src/components/IllustratedHeaderPageLayout.tsx
+++ b/src/components/IllustratedHeaderPageLayout.tsx
@@ -21,6 +21,8 @@ type IllustratedHeaderPageLayoutProps = HeaderPageLayoutProps & {
function IllustratedHeaderPageLayout({backgroundColor, children, illustration, overlayContent, ...rest}: IllustratedHeaderPageLayoutProps) {
const theme = useTheme();
const styles = useThemeStyles();
+ const shouldLimitHeight = !rest.shouldShowBackButton;
+
return (
{overlayContent?.()}
>
}
- headerContainerStyles={[styles.justifyContentCenter, styles.w100]}
+ headerContainerStyles={[styles.justifyContentCenter, styles.w100, shouldLimitHeight && styles.centralPaneAnimation]}
// eslint-disable-next-line react/jsx-props-no-spreading
{...rest}
>
diff --git a/src/components/Image/BaseImage.native.tsx b/src/components/Image/BaseImage.native.tsx
deleted file mode 100644
index c517efd04515..000000000000
--- a/src/components/Image/BaseImage.native.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-import {Image as ExpoImage} from 'expo-image';
-import type {ImageProps as ExpoImageProps, ImageLoadEventData} from 'expo-image';
-import {useCallback} from 'react';
-import type {BaseImageProps} from './types';
-
-function BaseImage({onLoad, ...props}: ExpoImageProps & BaseImageProps) {
- const imageLoadedSuccessfully = useCallback(
- (event: ImageLoadEventData) => {
- if (!onLoad) {
- return;
- }
-
- // We override `onLoad`, so both web and native have the same signature
- const {width, height} = event.source;
- onLoad({nativeEvent: {width, height}});
- },
- [onLoad],
- );
-
- return (
-
- );
-}
-
-BaseImage.displayName = 'BaseImage';
-
-export default BaseImage;
diff --git a/src/components/Image/BaseImage.tsx b/src/components/Image/BaseImage.tsx
deleted file mode 100644
index ebdd76840267..000000000000
--- a/src/components/Image/BaseImage.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-import React, {useCallback} from 'react';
-import {Image as RNImage} from 'react-native';
-import type {ImageLoadEventData, ImageProps as WebImageProps} from 'react-native';
-import type {BaseImageProps} from './types';
-
-function BaseImage({onLoad, ...props}: WebImageProps & BaseImageProps) {
- const imageLoadedSuccessfully = useCallback(
- (event: {nativeEvent: ImageLoadEventData}) => {
- if (!onLoad) {
- return;
- }
-
- // We override `onLoad`, so both web and native have the same signature
- const {width, height} = event.nativeEvent.source;
- onLoad({nativeEvent: {width, height}});
- },
- [onLoad],
- );
-
- return (
-
- );
-}
-
-BaseImage.displayName = 'BaseImage';
-
-export default BaseImage;
diff --git a/src/components/Image/index.js b/src/components/Image/index.js
index 8cee1cf95e14..ef1a69e19c12 100644
--- a/src/components/Image/index.js
+++ b/src/components/Image/index.js
@@ -1,35 +1,51 @@
import lodashGet from 'lodash/get';
-import React, {useMemo} from 'react';
+import React, {useEffect, useMemo} from 'react';
+import {Image as RNImage} from 'react-native';
import {withOnyx} from 'react-native-onyx';
-import CONST from '@src/CONST';
+import _ from 'underscore';
import ONYXKEYS from '@src/ONYXKEYS';
-import BaseImage from './BaseImage';
import {defaultProps, imagePropTypes} from './imagePropTypes';
import RESIZE_MODES from './resizeModes';
-function Image({source: propsSource, isAuthTokenRequired, session, ...forwardedProps}) {
- // Update the source to include the auth token if required
+function Image(props) {
+ const {source: propsSource, isAuthTokenRequired, onLoad, session} = props;
+ /**
+ * Check if the image source is a URL - if so the `encryptedAuthToken` is appended
+ * to the source.
+ */
const source = useMemo(() => {
- if (typeof lodashGet(propsSource, 'uri') === 'number') {
- return propsSource.uri;
+ if (isAuthTokenRequired) {
+ // There is currently a `react-native-web` bug preventing the authToken being passed
+ // in the headers of the image request so the authToken is added as a query param.
+ // On native the authToken IS passed in the image request headers
+ const authToken = lodashGet(session, 'encryptedAuthToken', null);
+ return {uri: `${propsSource.uri}?encryptedAuthToken=${encodeURIComponent(authToken)}`};
}
- if (typeof propsSource !== 'number' && isAuthTokenRequired) {
- const authToken = lodashGet(session, 'encryptedAuthToken');
- return {
- ...propsSource,
- headers: {
- [CONST.CHAT_ATTACHMENT_TOKEN_KEY]: authToken,
- },
- };
- }
-
return propsSource;
// The session prop is not required, as it causes the image to reload whenever the session changes. For more information, please refer to issue #26034.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [propsSource, isAuthTokenRequired]);
+ /**
+ * The natural image dimensions are retrieved using the updated source
+ * and as a result the `onLoad` event needs to be manually invoked to return these dimensions
+ */
+ useEffect(() => {
+ // If an onLoad callback was specified then manually call it and pass
+ // the natural image dimensions to match the native API
+ if (onLoad == null) {
+ return;
+ }
+ RNImage.getSize(source.uri, (width, height) => {
+ onLoad({nativeEvent: {width, height}});
+ });
+ }, [onLoad, source]);
+
+ // Omit the props which the underlying RNImage won't use
+ const forwardedProps = _.omit(props, ['source', 'onLoad', 'session', 'isAuthTokenRequired']);
+
return (
- {
+ const {width, height, url} = evt.source;
+ dimensionsCache.set(url, {width, height});
+ if (props.onLoad) {
+ props.onLoad({nativeEvent: {width, height}});
+ }
+ }}
+ />
+ );
+}
+
+Image.propTypes = imagePropTypes;
+Image.defaultProps = defaultProps;
+Image.displayName = 'Image';
+const ImageWithOnyx = withOnyx({
+ session: {
+ key: ONYXKEYS.SESSION,
+ },
+})(Image);
+ImageWithOnyx.resizeMode = RESIZE_MODES;
+ImageWithOnyx.resolveDimensions = resolveDimensions;
+
+export default ImageWithOnyx;
diff --git a/src/components/Image/types.ts b/src/components/Image/types.ts
deleted file mode 100644
index 5a4c94364a46..000000000000
--- a/src/components/Image/types.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-type BaseImageProps = {
- /** Event called with image dimensions when image is loaded */
- onLoad?: (event: {nativeEvent: {width: number; height: number}}) => void;
-};
-
-export type {BaseImageProps};
-
-export default BaseImageProps;
diff --git a/src/components/Indicator.tsx b/src/components/Indicator.tsx
index 486189c66710..1420a6abe189 100644
--- a/src/components/Indicator.tsx
+++ b/src/components/Indicator.tsx
@@ -1,24 +1,17 @@
import React from 'react';
import {StyleSheet, View} from 'react-native';
-import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
+import type {OnyxEntry} from 'react-native-onyx';
import {withOnyx} from 'react-native-onyx';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
-import * as PolicyUtils from '@libs/PolicyUtils';
import * as UserUtils from '@libs/UserUtils';
import * as PaymentMethods from '@userActions/PaymentMethods';
import ONYXKEYS from '@src/ONYXKEYS';
-import type {BankAccountList, FundList, LoginList, Policy, PolicyMembers, ReimbursementAccount, UserWallet, WalletTerms} from '@src/types/onyx';
+import type {BankAccountList, FundList, LoginList, UserWallet, WalletTerms} from '@src/types/onyx';
type CheckingMethod = () => boolean;
type IndicatorOnyxProps = {
- /** The employee list of all policies (coming from Onyx) */
- allPolicyMembers: OnyxCollection;
-
- /** All the user's policies (from Onyx via withFullPolicy) */
- policies: OnyxCollection;
-
/** List of bank accounts */
bankAccountList: OnyxEntry;
@@ -28,9 +21,6 @@ type IndicatorOnyxProps = {
/** The user's wallet (coming from Onyx) */
userWallet: OnyxEntry;
- /** Bank account attached to free plan */
- reimbursementAccount: OnyxEntry;
-
/** Information about the user accepting the terms for payments */
walletTerms: OnyxEntry;
@@ -40,25 +30,16 @@ type IndicatorOnyxProps = {
type IndicatorProps = IndicatorOnyxProps;
-function Indicator({reimbursementAccount, allPolicyMembers, policies, bankAccountList, fundList, userWallet, walletTerms, loginList}: IndicatorOnyxProps) {
+function Indicator({bankAccountList, fundList, userWallet, walletTerms, loginList}: IndicatorOnyxProps) {
const theme = useTheme();
const styles = useThemeStyles();
- // If a policy was just deleted from Onyx, then Onyx will pass a null value to the props, and
- // those should be cleaned out before doing any error checking
- const cleanPolicies = Object.fromEntries(Object.entries(policies ?? {}).filter(([, policy]) => !!policy));
- const cleanAllPolicyMembers = Object.fromEntries(Object.entries(allPolicyMembers ?? {}).filter(([, policyMembers]) => !!policyMembers));
-
// All of the error & info-checking methods are put into an array. This is so that using _.some() will return
// early as soon as the first error / info condition is returned. This makes the checks very efficient since
// we only care if a single error / info condition exists anywhere.
const errorCheckingMethods: CheckingMethod[] = [
() => Object.keys(userWallet?.errors ?? {}).length > 0,
() => PaymentMethods.hasPaymentMethodError(bankAccountList, fundList),
- () => Object.values(cleanPolicies).some(PolicyUtils.hasPolicyError),
- () => Object.values(cleanPolicies).some(PolicyUtils.hasCustomUnitsError),
- () => Object.values(cleanAllPolicyMembers).some(PolicyUtils.hasPolicyMemberError),
- () => Object.keys(reimbursementAccount?.errors ?? {}).length > 0,
() => !!loginList && UserUtils.hasLoginListError(loginList),
// Wallet term errors that are not caused by an IOU (we show the red brick indicator for those in the LHN instead)
@@ -77,18 +58,9 @@ function Indicator({reimbursementAccount, allPolicyMembers, policies, bankAccoun
Indicator.displayName = 'Indicator';
export default withOnyx({
- allPolicyMembers: {
- key: ONYXKEYS.COLLECTION.POLICY_MEMBERS,
- },
- policies: {
- key: ONYXKEYS.COLLECTION.POLICY,
- },
bankAccountList: {
key: ONYXKEYS.BANK_ACCOUNT_LIST,
},
- reimbursementAccount: {
- key: ONYXKEYS.REIMBURSEMENT_ACCOUNT,
- },
fundList: {
key: ONYXKEYS.FUND_LIST,
},
diff --git a/src/components/LocaleContextProvider.tsx b/src/components/LocaleContextProvider.tsx
index 7313bb4aa7bb..48e9aa49d0de 100644
--- a/src/components/LocaleContextProvider.tsx
+++ b/src/components/LocaleContextProvider.tsx
@@ -45,7 +45,7 @@ type LocaleContextProps = {
/** Returns a locally converted phone number for numbers from the same region
* and an internationally converted phone number with the country code for numbers from other regions */
- formatPhoneNumber: (phoneNumber: string) => string;
+ formatPhoneNumber: (phoneNumber: string | undefined) => string;
/** Gets the locale digit corresponding to a standard digit */
toLocaleDigit: (digit: string) => string;
diff --git a/src/components/MagicCodeInput.tsx b/src/components/MagicCodeInput.tsx
index 4a6d87b48e38..9af07bef6af1 100644
--- a/src/components/MagicCodeInput.tsx
+++ b/src/components/MagicCodeInput.tsx
@@ -278,7 +278,7 @@ function MagicCodeInput(
const indexToFocus = numbers[editIndex] === CONST.MAGIC_CODE_EMPTY_CHAR ? indexBeforeLastEditIndex : editIndex;
const formElement = inputRefs.current as HTMLFormElement | null;
- (formElement?.[indexToFocus] as HTMLInputElement).focus();
+ (formElement?.[indexToFocus] as HTMLInputElement)?.focus();
onChangeTextProp(value.substring(0, indexToFocus));
return;
diff --git a/src/components/MenuItem.tsx b/src/components/MenuItem.tsx
index e53c7915da8a..cc0de763f515 100644
--- a/src/components/MenuItem.tsx
+++ b/src/components/MenuItem.tsx
@@ -197,6 +197,9 @@ type MenuItemProps = (IconProps | AvatarProps | NoIcon) & {
/** Should we grey out the menu item when it is disabled? */
shouldGreyOutWhenDisabled?: boolean;
+ /** Should we use default cursor for disabled content */
+ shouldUseDefaultCursorWhenDisabled?: boolean;
+
/** The action accept for anonymous user or not */
isAnonymousAction?: boolean;
@@ -223,6 +226,9 @@ type MenuItemProps = (IconProps | AvatarProps | NoIcon) & {
/** Determines how the icon should be resized to fit its container */
contentFit?: ImageContentFit;
+
+ /** Is this in the Pane */
+ isPaneMenu?: boolean;
};
function MenuItem(
@@ -277,6 +283,7 @@ function MenuItem(
brickRoadIndicator,
shouldRenderAsHTML = false,
shouldGreyOutWhenDisabled = true,
+ shouldUseDefaultCursorWhenDisabled = false,
isAnonymousAction = false,
shouldBlockSelection = false,
shouldParseTitle = false,
@@ -285,6 +292,7 @@ function MenuItem(
titleWithTooltips,
displayInDefaultIconColor = false,
contentFit = 'cover',
+ isPaneMenu = false,
}: MenuItemProps,
ref: ForwardedRef,
) {
@@ -389,11 +397,12 @@ function MenuItem(
combinedStyle,
!interactive && styles.cursorDefault,
StyleUtils.getButtonBackgroundColorStyle(getButtonState(focused || isHovered, pressed, success, disabled, interactive), true),
- (isHovered || pressed) && hoverAndPressStyle,
+ !focused && (isHovered || pressed) && hoverAndPressStyle,
...(Array.isArray(wrapperStyle) ? wrapperStyle : [wrapperStyle]),
shouldGreyOutWhenDisabled && disabled && styles.buttonOpacityDisabled,
] as StyleProp
}
+ disabledStyle={shouldUseDefaultCursorWhenDisabled && [styles.cursorDefault]}
disabled={disabled}
ref={ref}
role={CONST.ROLE.MENUITEM}
@@ -408,7 +417,7 @@ function MenuItem(
{label}
)}
-
+
{!!icon && Array.isArray(icon) && (
)}
@@ -563,7 +573,7 @@ function MenuItem(
)}
{!!brickRoadIndicator && (
-
+
)}
{shouldShowRightIcon && (
-
+
;
+ messages: Record;
/** The type of message, 'error' shows a red dot, 'success' shows a green dot */
type: 'error' | 'success';
diff --git a/src/components/MultipleAvatars.tsx b/src/components/MultipleAvatars.tsx
index a6f34cd459fc..1d1eea0d20ba 100644
--- a/src/components/MultipleAvatars.tsx
+++ b/src/components/MultipleAvatars.tsx
@@ -153,6 +153,7 @@ function MultipleAvatars({
e === null);
+ const errorMessages = omitBy(errors, (e: string | ReceiptError) => e === null);
const hasErrorMessages = !isEmptyObject(errorMessages);
const isOfflinePendingAction = !!isOffline && !!pendingAction;
const isUpdateOrDeleteError = hasErrors && (pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE);
diff --git a/src/components/OptionRow.tsx b/src/components/OptionRow.tsx
index 97e85cacf42d..93c744225237 100644
--- a/src/components/OptionRow.tsx
+++ b/src/components/OptionRow.tsx
@@ -209,14 +209,14 @@ function OptionRow({
) : (
))}
@@ -246,7 +246,7 @@ function OptionRow({
{option.descriptiveText}
) : null}
- {option.brickRoadIndicator === CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR && (
+ {!isSelected && option.brickRoadIndicator === CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR && (
)}
+ {!isSelected && option.brickRoadIndicator === CONST.BRICK_ROAD_INDICATOR_STATUS.INFO && (
+
+
+
+ )}
{showSelectedState && (
<>
{shouldShowSelectedStateAsButton && !isSelected ? (
@@ -270,8 +278,12 @@ function OptionRow({
disabled={isDisabled}
role={CONST.ROLE.BUTTON}
accessibilityLabel={CONST.ROLE.BUTTON}
+ style={[styles.ml2, styles.optionSelectCircle]}
>
-
+
)}
>
diff --git a/src/components/OptionsList/BaseOptionsList.tsx b/src/components/OptionsList/BaseOptionsList.tsx
index 8a39c8e1f029..6f265bfb8440 100644
--- a/src/components/OptionsList/BaseOptionsList.tsx
+++ b/src/components/OptionsList/BaseOptionsList.tsx
@@ -175,11 +175,7 @@ function BaseOptionsList(
const renderItem: SectionListRenderItem = ({item, index, section}) => {
const isItemDisabled = isDisabled || !!section.isDisabled || !!item.isDisabled;
const isSelected = selectedOptions?.some((option) => {
- if (option.accountID && option.accountID === item.accountID) {
- return true;
- }
-
- if (option.reportID && option.reportID === item.reportID) {
+ if (option.keyForList && option.keyForList === item.keyForList) {
return true;
}
@@ -204,7 +200,7 @@ function BaseOptionsList(
selectedStateButtonText={multipleOptionSelectorButtonText}
onSelectedStatePressed={onAddToSelection}
highlightSelected={highlightSelectedOptions}
- boldStyle={boldStyle}
+ boldStyle={item.boldStyle ?? boldStyle}
isDisabled={isItemDisabled}
shouldHaveOptionSeparator={index > 0 && shouldHaveOptionSeparator}
shouldDisableRowInnerPadding={shouldDisableRowInnerPadding}
diff --git a/src/components/OptionsList/types.ts b/src/components/OptionsList/types.ts
index 4c9a0e60a7e3..fa3ef8df56f6 100644
--- a/src/components/OptionsList/types.ts
+++ b/src/components/OptionsList/types.ts
@@ -105,7 +105,7 @@ type OptionsListProps = {
bounces?: boolean;
/** Custom content to display in the floating footer */
- renderFooterContent?: () => JSX.Element;
+ renderFooterContent?: JSX.Element;
/** Whether to show a button pill instead of a standard tickbox */
shouldShowMultipleOptionSelectorAsButton?: boolean;
diff --git a/src/components/OptionsSelector/BaseOptionsSelector.js b/src/components/OptionsSelector/BaseOptionsSelector.js
index a466dd2fc77b..345680e809f3 100755
--- a/src/components/OptionsSelector/BaseOptionsSelector.js
+++ b/src/components/OptionsSelector/BaseOptionsSelector.js
@@ -41,6 +41,9 @@ const propTypes = {
/** Whether referral CTA should be displayed */
shouldShowReferralCTA: PropTypes.bool,
+ /** A method triggered when the user closes the call to action banner */
+ onCallToActionClosed: PropTypes.func,
+
/** Referral content type */
referralContentType: PropTypes.string,
@@ -53,6 +56,7 @@ const propTypes = {
const defaultProps = {
shouldDelayFocus: false,
shouldShowReferralCTA: false,
+ onCallToActionClosed: () => {},
referralContentType: CONST.REFERRAL_PROGRAM.CONTENT_TYPES.REFER_FRIEND,
safeAreaPaddingBottomStyle: {},
contentContainerStyles: [],
@@ -68,7 +72,7 @@ class BaseOptionsSelector extends Component {
this.updateFocusedIndex = this.updateFocusedIndex.bind(this);
this.scrollToIndex = this.scrollToIndex.bind(this);
this.selectRow = this.selectRow.bind(this);
- this.handleReferralModal = this.handleReferralModal.bind(this);
+ this.closeReferralModal = this.closeReferralModal.bind(this);
this.selectFocusedOption = this.selectFocusedOption.bind(this);
this.addToSelection = this.addToSelection.bind(this);
this.updateSearchValue = this.updateSearchValue.bind(this);
@@ -215,11 +219,7 @@ class BaseOptionsSelector extends Component {
const indexOfInitiallyFocusedOption = _.findIndex(allOptions, (option) => option.keyForList === this.props.initiallyFocusedOptionKey);
- if (indexOfInitiallyFocusedOption >= 0) {
- return indexOfInitiallyFocusedOption;
- }
-
- return defaultIndex;
+ return indexOfInitiallyFocusedOption;
}
/**
@@ -266,8 +266,9 @@ class BaseOptionsSelector extends Component {
this.props.onChangeText(value);
}
- handleReferralModal() {
+ closeReferralModal() {
this.setState((prevState) => ({shouldShowReferralModal: !prevState.shouldShowReferralModal}));
+ this.props.onCallToActionClosed(this.props.referralContentType);
}
handleFocusIn() {
@@ -524,6 +525,7 @@ class BaseOptionsSelector extends Component {
spellCheck={false}
shouldInterceptSwipe={this.props.shouldTextInputInterceptSwipe}
isLoading={this.props.isLoadingNewOptions}
+ iconLeft={this.props.textIconLeft}
testID="options-selector-input"
/>
);
@@ -534,6 +536,7 @@ class BaseOptionsSelector extends Component {
onSelectRow={this.props.onSelectRow ? this.selectRow : undefined}
sections={this.state.sections}
focusedIndex={this.state.focusedIndex}
+ disableFocusOptions={this.props.disableFocusOptions}
selectedOptions={this.props.selectedOptions}
canSelectMultipleOptions={this.props.canSelectMultipleOptions}
shouldShowMultipleOptionSelectorAsButton={this.props.shouldShowMultipleOptionSelectorAsButton}
@@ -566,7 +569,7 @@ class BaseOptionsSelector extends Component {
shouldPreventDefaultFocusOnSelectRow={this.props.shouldPreventDefaultFocusOnSelectRow}
nestedScrollEnabled={this.props.nestedScrollEnabled}
bounces={!this.props.shouldTextInputAppearBelowOptions || !this.props.shouldAllowScrollingChildren}
- renderFooterContent={() =>
+ renderFooterContent={
shouldShowShowMoreButton && (
)}
diff --git a/src/components/OptionsSelector/optionsSelectorPropTypes.js b/src/components/OptionsSelector/optionsSelectorPropTypes.js
index e52187fa76d7..8e58a7ffdb86 100644
--- a/src/components/OptionsSelector/optionsSelectorPropTypes.js
+++ b/src/components/OptionsSelector/optionsSelectorPropTypes.js
@@ -1,4 +1,5 @@
import PropTypes from 'prop-types';
+import sourcePropTypes from '@components/Image/sourcePropTypes';
import optionPropTypes from '@components/optionPropTypes';
import stylePropTypes from '@styles/stylePropTypes';
import CONST from '@src/CONST';
@@ -72,6 +73,9 @@ const propTypes = {
/** Whether to disable interactivity of option rows */
isDisabled: PropTypes.bool,
+ /** Whether to disable focus options of rows */
+ disableFocusOptions: PropTypes.bool,
+
/** Display the text of the option in bold font style */
boldStyle: PropTypes.bool,
@@ -134,6 +138,9 @@ const propTypes = {
/** Whether nested scroll of options is enabled, true by default */
nestedScrollEnabled: PropTypes.bool,
+
+ /** Left icon to display in TextInput */
+ textIconLeft: sourcePropTypes,
};
const defaultProps = {
@@ -163,6 +170,7 @@ const defaultProps = {
shouldShowOptions: true,
disableArrowKeysActions: false,
isDisabled: false,
+ disableFocusOptions: false,
shouldHaveOptionSeparator: false,
initiallyFocusedOptionKey: undefined,
maxLength: CONST.SEARCH_MAX_LENGTH,
@@ -174,6 +182,7 @@ const defaultProps = {
shouldTextInputInterceptSwipe: false,
shouldAllowScrollingChildren: false,
nestedScrollEnabled: true,
+ textIconLeft: undefined,
};
export {propTypes, defaultProps};
diff --git a/src/components/PopoverProvider/index.tsx b/src/components/PopoverProvider/index.tsx
index a738d1f9798a..69728d7be126 100644
--- a/src/components/PopoverProvider/index.tsx
+++ b/src/components/PopoverProvider/index.tsx
@@ -27,9 +27,6 @@ function PopoverContextProvider(props: PopoverContextProps) {
}
activePopoverRef.current.close();
- if (activePopoverRef.current.onCloseCallback) {
- activePopoverRef.current.onCloseCallback();
- }
activePopoverRef.current = null;
setIsOpen(false);
}, []);
@@ -107,9 +104,6 @@ function PopoverContextProvider(props: PopoverContextProps) {
closePopover(activePopoverRef.current.anchorRef);
}
activePopoverRef.current = popoverParams;
- if (popoverParams?.onOpenCallback) {
- popoverParams.onOpenCallback();
- }
setIsOpen(true);
},
[closePopover],
diff --git a/src/components/PopoverProvider/types.ts b/src/components/PopoverProvider/types.ts
index 49705d7ea7a8..2a366ae2a712 100644
--- a/src/components/PopoverProvider/types.ts
+++ b/src/components/PopoverProvider/types.ts
@@ -16,8 +16,6 @@ type AnchorRef = {
ref: RefObject;
close: (anchorRef?: RefObject) => void;
anchorRef: RefObject;
- onOpenCallback?: () => void;
- onCloseCallback?: () => void;
};
export type {PopoverContextProps, PopoverContextValue, AnchorRef};
diff --git a/src/components/Pressable/PressableWithoutFeedback.tsx b/src/components/Pressable/PressableWithoutFeedback.tsx
index f95050dd649e..d70526129c46 100644
--- a/src/components/Pressable/PressableWithoutFeedback.tsx
+++ b/src/components/Pressable/PressableWithoutFeedback.tsx
@@ -4,7 +4,7 @@ import type {PressableRef} from './GenericPressable/types';
import type PressableProps from './GenericPressable/types';
function PressableWithoutFeedback(
- {pressStyle, hoverStyle, focusStyle, disabledStyle, screenReaderActiveStyle, shouldUseHapticsOnPress, shouldUseHapticsOnLongPress, ...rest}: PressableProps,
+ {pressStyle, hoverStyle, focusStyle, screenReaderActiveStyle, shouldUseHapticsOnPress, shouldUseHapticsOnLongPress, ...rest}: PressableProps,
ref: PressableRef,
) {
return (
diff --git a/src/components/PurposeForUsingExpensifyModal.tsx b/src/components/PurposeForUsingExpensifyModal.tsx
index d464296edd84..e65646aeac84 100644
--- a/src/components/PurposeForUsingExpensifyModal.tsx
+++ b/src/components/PurposeForUsingExpensifyModal.tsx
@@ -99,7 +99,7 @@ function PurposeForUsingExpensifyModal({isLoadingApp = false}: PurposeForUsingEx
const navigationState = navigation.getState();
const routes = navigationState.routes;
const currentRoute = routes[navigationState.index];
- if (currentRoute && NAVIGATORS.CENTRAL_PANE_NAVIGATOR !== currentRoute.name && currentRoute.name !== SCREENS.HOME) {
+ if (currentRoute && NAVIGATORS.BOTTOM_TAB_NAVIGATOR !== currentRoute.name) {
return;
}
diff --git a/src/components/QRShare/index.tsx b/src/components/QRShare/index.tsx
index c7e9e7637a6c..45a4a4fd4964 100644
--- a/src/components/QRShare/index.tsx
+++ b/src/components/QRShare/index.tsx
@@ -9,12 +9,15 @@ import QRCode from '@components/QRCode';
import Text from '@components/Text';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
+import useWindowDimensions from '@hooks/useWindowDimensions';
import variables from '@styles/variables';
+import CONST from '@src/CONST';
import type {QRShareHandle, QRShareProps} from './types';
function QRShare({url, title, subtitle, logo, logoRatio, logoMarginRatio}: QRShareProps, ref: ForwardedRef) {
const styles = useThemeStyles();
const theme = useTheme();
+ const {isSmallScreenWidth} = useWindowDimensions();
const [qrCodeSize, setQrCodeSize] = useState(1);
const svgRef = useRef();
@@ -29,7 +32,11 @@ function QRShare({url, title, subtitle, logo, logoRatio, logoMarginRatio}: QRSha
const onLayout = (event: LayoutChangeEvent) => {
const containerWidth = event.nativeEvent.layout.width - variables.qrShareHorizontalPadding * 2 || 0;
- setQrCodeSize(Math.max(1, containerWidth));
+ if (isSmallScreenWidth) {
+ setQrCodeSize(Math.max(1, containerWidth));
+ return;
+ }
+ setQrCodeSize(Math.max(1, Math.min(containerWidth, CONST.CENTRAL_PANE_ANIMATION_HEIGHT)));
};
return (
diff --git a/src/components/ReferralProgramCTA.tsx b/src/components/ReferralProgramCTA.tsx
index 4a6b8b03f2b4..f1c7539cc6b5 100644
--- a/src/components/ReferralProgramCTA.tsx
+++ b/src/components/ReferralProgramCTA.tsx
@@ -9,6 +9,7 @@ import Icon from './Icon';
import {Close} from './Icon/Expensicons';
import {PressableWithoutFeedback} from './Pressable';
import Text from './Text';
+import Tooltip from './Tooltip';
type ReferralProgramCTAProps = {
referralContentType:
@@ -44,22 +45,24 @@ function ReferralProgramCTA({referralContentType, onCloseButtonPress = () => {}}
{translate(`referralProgram.${referralContentType}.buttonText2`)}
- {
- e.preventDefault();
- }}
- style={[styles.touchableButtonImage]}
- role={CONST.ACCESSIBILITY_ROLE.BUTTON}
- accessibilityLabel={translate('common.close')}
- >
-
-
+
+ {
+ e.preventDefault();
+ }}
+ style={[styles.touchableButtonImage]}
+ role={CONST.ACCESSIBILITY_ROLE.BUTTON}
+ accessibilityLabel={translate('common.close')}
+ >
+
+
+
);
}
diff --git a/src/components/ReportActionItem/MoneyRequestAction.js b/src/components/ReportActionItem/MoneyRequestAction.tsx
similarity index 58%
rename from src/components/ReportActionItem/MoneyRequestAction.js
rename to src/components/ReportActionItem/MoneyRequestAction.tsx
index 4fca8a0a1aea..ff29bf5b0ee8 100644
--- a/src/components/ReportActionItem/MoneyRequestAction.js
+++ b/src/components/ReportActionItem/MoneyRequestAction.tsx
@@ -1,83 +1,66 @@
-import lodashGet from 'lodash/get';
-import PropTypes from 'prop-types';
import React from 'react';
+import type {StyleProp, ViewStyle} from 'react-native';
import {withOnyx} from 'react-native-onyx';
-import _ from 'underscore';
-import networkPropTypes from '@components/networkPropTypes';
-import {withNetwork} from '@components/OnyxProvider';
-import refPropTypes from '@components/refPropTypes';
+import type {OnyxEntry} from 'react-native-onyx';
import RenderHTML from '@components/RenderHTML';
import useLocalize from '@hooks/useLocalize';
+import useNetwork from '@hooks/useNetwork';
import useThemeStyles from '@hooks/useThemeStyles';
-import compose from '@libs/compose';
import * as IOUUtils from '@libs/IOUUtils';
import Navigation from '@libs/Navigation/Navigation';
import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils';
import * as ReportActionsUtils from '@libs/ReportActionsUtils';
import * as ReportUtils from '@libs/ReportUtils';
-import reportActionPropTypes from '@pages/home/report/reportActionPropTypes';
-import iouReportPropTypes from '@pages/iouReportPropTypes';
-import reportPropTypes from '@pages/reportPropTypes';
+import type {ContextMenuAnchor} from '@pages/home/report/ContextMenu/ReportActionContextMenu';
import * as Report from '@userActions/Report';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
+import type * as OnyxTypes from '@src/types/onyx';
+import {isEmptyObject} from '@src/types/utils/EmptyObject';
import MoneyRequestPreview from './MoneyRequestPreview';
-const propTypes = {
+type MoneyRequestActionOnyxProps = {
+ /** Chat report associated with iouReport */
+ chatReport: OnyxEntry;
+
+ /** IOU report data object */
+ iouReport: OnyxEntry;
+
+ /** Report actions for this report */
+ reportActions: OnyxEntry;
+};
+
+type MoneyRequestActionProps = MoneyRequestActionOnyxProps & {
/** All the data of the action */
- action: PropTypes.shape(reportActionPropTypes).isRequired,
+ action: OnyxTypes.ReportAction;
/** The ID of the associated chatReport */
- chatReportID: PropTypes.string.isRequired,
+ chatReportID: string;
/** The ID of the associated request report */
- requestReportID: PropTypes.string.isRequired,
+ requestReportID: string;
/** The ID of the current report */
- reportID: PropTypes.string.isRequired,
+ reportID: string;
/** Is this IOUACTION the most recent? */
- isMostRecentIOUReportAction: PropTypes.bool.isRequired,
+ isMostRecentIOUReportAction: boolean;
/** Popover context menu anchor, used for showing context menu */
- contextMenuAnchor: refPropTypes,
+ contextMenuAnchor?: ContextMenuAnchor;
/** Callback for updating context menu active state, used for showing context menu */
- checkIfContextMenuActive: PropTypes.func,
-
- /* Onyx Props */
- /** chatReport associated with iouReport */
- chatReport: reportPropTypes,
-
- /** IOU report data object */
- iouReport: iouReportPropTypes,
-
- /** Array of report actions for this report */
- reportActions: PropTypes.objectOf(PropTypes.shape(reportActionPropTypes)),
+ checkIfContextMenuActive?: () => void;
/** Whether the IOU is hovered so we can modify its style */
- isHovered: PropTypes.bool,
-
- network: networkPropTypes.isRequired,
+ isHovered?: boolean;
/** Whether a message is a whisper */
- isWhisper: PropTypes.bool,
+ isWhisper?: boolean;
/** Styles to be assigned to Container */
- // eslint-disable-next-line react/forbid-prop-types
- style: PropTypes.arrayOf(PropTypes.object),
-};
-
-const defaultProps = {
- contextMenuAnchor: undefined,
- checkIfContextMenuActive: () => {},
- chatReport: {},
- iouReport: {},
- reportActions: {},
- isHovered: false,
- style: [],
- isWhisper: false,
+ style?: StyleProp;
};
function MoneyRequestAction({
@@ -87,31 +70,32 @@ function MoneyRequestAction({
reportID,
isMostRecentIOUReportAction,
contextMenuAnchor,
- checkIfContextMenuActive,
+ checkIfContextMenuActive = () => {},
chatReport,
iouReport,
reportActions,
- isHovered,
- network,
+ isHovered = false,
style,
- isWhisper,
-}) {
+ isWhisper = false,
+}: MoneyRequestActionProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
- const isSplitBillAction = lodashGet(action, 'originalMessage.type', '') === CONST.IOU.REPORT_ACTION_TYPE.SPLIT;
+ const {isOffline} = useNetwork();
+
+ const isSplitBillAction = action.actionName === CONST.REPORT.ACTIONS.TYPE.IOU && action.originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.SPLIT;
const onMoneyRequestPreviewPressed = () => {
if (isSplitBillAction) {
- const reportActionID = lodashGet(action, 'reportActionID', '0');
+ const reportActionID = action.reportActionID ?? '0';
Navigation.navigate(ROUTES.SPLIT_BILL_DETAILS.getRoute(chatReportID, reportActionID));
return;
}
// If the childReportID is not present, we need to create a new thread
- const childReportID = lodashGet(action, 'childReportID', 0);
+ const childReportID = action?.childReportID ?? '0';
if (!childReportID) {
const thread = ReportUtils.buildTransactionThread(action, requestReportID);
- const userLogins = PersonalDetailsUtils.getLoginsByAccountIDs(thread.participantAccountIDs);
+ const userLogins = PersonalDetailsUtils.getLoginsByAccountIDs(thread.participantAccountIDs ?? []);
Report.openReport(thread.reportID, userLogins, thread, action.reportActionID);
Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(thread.reportID));
return;
@@ -124,12 +108,12 @@ function MoneyRequestAction({
const isDeletedParentAction = ReportActionsUtils.isDeletedParentAction(action);
const isReversedTransaction = ReportActionsUtils.isReversedTransaction(action);
if (
- !_.isEmpty(iouReport) &&
- !_.isEmpty(reportActions) &&
- chatReport.iouReportID &&
+ !isEmptyObject(iouReport) &&
+ !isEmptyObject(reportActions) &&
+ chatReport?.iouReportID &&
isMostRecentIOUReportAction &&
action.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD &&
- network.isOffline
+ isOffline
) {
shouldShowPendingConversionMessage = IOUUtils.isIOUReportPendingCurrencyConversion(iouReport);
}
@@ -147,29 +131,24 @@ function MoneyRequestAction({
checkIfContextMenuActive={checkIfContextMenuActive}
shouldShowPendingConversionMessage={shouldShowPendingConversionMessage}
onPreviewPressed={onMoneyRequestPreviewPressed}
- containerStyles={[styles.cursorPointer, isHovered ? styles.reportPreviewBoxHoverBorder : undefined, ...style]}
+ containerStyles={[styles.cursorPointer, isHovered ? styles.reportPreviewBoxHoverBorder : undefined, style]}
isHovered={isHovered}
isWhisper={isWhisper}
/>
);
}
-MoneyRequestAction.propTypes = propTypes;
-MoneyRequestAction.defaultProps = defaultProps;
MoneyRequestAction.displayName = 'MoneyRequestAction';
-export default compose(
- withOnyx({
- chatReport: {
- key: ({chatReportID}) => `${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`,
- },
- iouReport: {
- key: ({requestReportID}) => `${ONYXKEYS.COLLECTION.REPORT}${requestReportID}`,
- },
- reportActions: {
- key: ({chatReportID}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`,
- canEvict: false,
- },
- }),
- withNetwork(),
-)(MoneyRequestAction);
+export default withOnyx({
+ chatReport: {
+ key: ({chatReportID}) => `${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`,
+ },
+ iouReport: {
+ key: ({requestReportID}) => `${ONYXKEYS.COLLECTION.REPORT}${requestReportID}`,
+ },
+ reportActions: {
+ key: ({chatReportID}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`,
+ canEvict: false,
+ },
+})(MoneyRequestAction);
diff --git a/src/components/ReportActionItem/MoneyRequestPreview.js b/src/components/ReportActionItem/MoneyRequestPreview.tsx
similarity index 62%
rename from src/components/ReportActionItem/MoneyRequestPreview.js
rename to src/components/ReportActionItem/MoneyRequestPreview.tsx
index 7b38dcd34109..f321c63375d0 100644
--- a/src/components/ReportActionItem/MoneyRequestPreview.js
+++ b/src/components/ReportActionItem/MoneyRequestPreview.tsx
@@ -1,22 +1,20 @@
import ExpensiMark from 'expensify-common/lib/ExpensiMark';
import {truncate} from 'lodash';
-import lodashGet from 'lodash/get';
-import PropTypes from 'prop-types';
+import lodashSortBy from 'lodash/sortBy';
import React from 'react';
import {View} from 'react-native';
+import type {GestureResponderEvent, StyleProp, ViewStyle} from 'react-native';
import {withOnyx} from 'react-native-onyx';
-import _ from 'underscore';
+import type {OnyxEntry} from 'react-native-onyx';
import Icon from '@components/Icon';
import * as Expensicons from '@components/Icon/Expensicons';
import MoneyRequestSkeletonView from '@components/MoneyRequestSkeletonView';
import MultipleAvatars from '@components/MultipleAvatars';
import OfflineWithFeedback from '@components/OfflineWithFeedback';
import PressableWithFeedback from '@components/Pressable/PressableWithoutFeedback';
-import refPropTypes from '@components/refPropTypes';
import RenderHTML from '@components/RenderHTML';
import {showContextMenuForReport} from '@components/ShowContextMenuContext';
import Text from '@components/Text';
-import transactionPropTypes from '@components/transactionPropTypes';
import useLocalize from '@hooks/useLocalize';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
@@ -31,107 +29,99 @@ import * as ReceiptUtils from '@libs/ReceiptUtils';
import * as ReportActionsUtils from '@libs/ReportActionsUtils';
import * as ReportUtils from '@libs/ReportUtils';
import * as TransactionUtils from '@libs/TransactionUtils';
-import walletTermsPropTypes from '@pages/EnablePayments/walletTermsPropTypes';
-import reportActionPropTypes from '@pages/home/report/reportActionPropTypes';
-import iouReportPropTypes from '@pages/iouReportPropTypes';
-import reportPropTypes from '@pages/reportPropTypes';
+import type {ContextMenuAnchor} from '@pages/home/report/ContextMenu/ReportActionContextMenu';
import * as PaymentMethods from '@userActions/PaymentMethods';
import * as Report from '@userActions/Report';
import CONST from '@src/CONST';
import * as Localize from '@src/libs/Localize';
import ONYXKEYS from '@src/ONYXKEYS';
+import type * as OnyxTypes from '@src/types/onyx';
+import type {IOUMessage} from '@src/types/onyx/OriginalMessage';
+import {isEmptyObject} from '@src/types/utils/EmptyObject';
+import type {EmptyObject} from '@src/types/utils/EmptyObject';
import ReportActionItemImages from './ReportActionItemImages';
-const propTypes = {
+type MoneyRequestPreviewOnyxProps = {
+ /** All of the personal details for everyone */
+ personalDetails: OnyxEntry;
+
+ /** Chat report associated with iouReport */
+ chatReport: OnyxEntry;
+
+ /** IOU report data object */
+ iouReport: OnyxEntry;
+
+ /** Session info for the currently logged in user. */
+ session: OnyxEntry;
+
+ /** The transaction attached to the action.message.iouTransactionID */
+ transaction: OnyxEntry;
+
+ /** Information about the user accepting the terms for payments */
+ walletTerms: OnyxEntry;
+};
+
+type MoneyRequestPreviewProps = MoneyRequestPreviewOnyxProps & {
/** The active IOUReport, used for Onyx subscription */
+ // The iouReportID is used inside withOnyx HOC
// eslint-disable-next-line react/no-unused-prop-types
- iouReportID: PropTypes.string.isRequired,
+ iouReportID: string;
/** The associated chatReport */
- chatReportID: PropTypes.string.isRequired,
+ chatReportID: string;
/** The ID of the current report */
- reportID: PropTypes.string.isRequired,
+ reportID: string;
/** Callback for the preview pressed */
- onPreviewPressed: PropTypes.func,
+ onPreviewPressed: (event?: GestureResponderEvent | KeyboardEvent) => void;
/** All the data of the action, used for showing context menu */
- action: PropTypes.shape(reportActionPropTypes),
+ action: OnyxTypes.ReportAction;
/** Popover context menu anchor, used for showing context menu */
- contextMenuAnchor: refPropTypes,
+ contextMenuAnchor?: ContextMenuAnchor;
/** Callback for updating context menu active state, used for showing context menu */
- checkIfContextMenuActive: PropTypes.func,
+ checkIfContextMenuActive?: () => void;
/** Extra styles to pass to View wrapper */
- // eslint-disable-next-line react/forbid-prop-types
- containerStyles: PropTypes.arrayOf(PropTypes.object),
-
- /* Onyx Props */
-
- /** chatReport associated with iouReport */
- chatReport: reportPropTypes,
-
- /** IOU report data object */
- iouReport: iouReportPropTypes,
+ containerStyles?: StyleProp;
/** True if this is this IOU is a split instead of a 1:1 request */
- isBillSplit: PropTypes.bool.isRequired,
+ isBillSplit: boolean;
/** True if the IOU Preview card is hovered */
- isHovered: PropTypes.bool,
-
- /** All of the personal details for everyone */
- personalDetails: PropTypes.objectOf(
- PropTypes.shape({
- /** This is either the user's full name, or their login if full name is an empty string */
- displayName: PropTypes.string,
- }),
- ),
-
- /** The transaction attached to the action.message.iouTransactionID */
- transaction: transactionPropTypes,
-
- /** Session info for the currently logged in user. */
- session: PropTypes.shape({
- /** Currently logged in user email */
- email: PropTypes.string,
- }),
-
- /** Information about the user accepting the terms for payments */
- walletTerms: walletTermsPropTypes,
+ isHovered?: boolean;
/** Whether or not an IOU report contains money requests in a different currency
* that are either created or cancelled offline, and thus haven't been converted to the report's currency yet
*/
- shouldShowPendingConversionMessage: PropTypes.bool,
+ shouldShowPendingConversionMessage?: boolean;
/** Whether a message is a whisper */
- isWhisper: PropTypes.bool,
+ isWhisper?: boolean;
};
-const defaultProps = {
- iouReport: {},
- onPreviewPressed: null,
- action: undefined,
- contextMenuAnchor: undefined,
- checkIfContextMenuActive: () => {},
- containerStyles: [],
- walletTerms: {},
- chatReport: {},
- isHovered: false,
- personalDetails: {},
- session: {
- email: null,
- },
- transaction: {},
- shouldShowPendingConversionMessage: false,
- isWhisper: false,
-};
-
-function MoneyRequestPreview(props) {
+function MoneyRequestPreview({
+ iouReport,
+ isBillSplit,
+ session,
+ action,
+ personalDetails,
+ chatReport,
+ transaction,
+ contextMenuAnchor,
+ chatReportID,
+ reportID,
+ onPreviewPressed,
+ containerStyles,
+ walletTerms,
+ checkIfContextMenuActive = () => {},
+ shouldShowPendingConversionMessage = false,
+ isHovered = false,
+ isWhisper = false,
+}: MoneyRequestPreviewProps) {
const theme = useTheme();
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
@@ -139,40 +129,40 @@ function MoneyRequestPreview(props) {
const {isSmallScreenWidth, windowWidth} = useWindowDimensions();
const parser = new ExpensiMark();
- if (_.isEmpty(props.iouReport) && !props.isBillSplit) {
+ if (isEmptyObject(iouReport) && !isBillSplit) {
return null;
}
- const sessionAccountID = lodashGet(props.session, 'accountID', null);
- const managerID = props.iouReport.managerID || '';
- const ownerAccountID = props.iouReport.ownerAccountID || '';
- const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(props.chatReport);
+ const sessionAccountID = session?.accountID;
+ const managerID = iouReport?.managerID ?? -1;
+ const ownerAccountID = iouReport?.ownerAccountID ?? -1;
+ const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(chatReport);
- const participantAccountIDs = props.isBillSplit ? lodashGet(props.action, 'originalMessage.participantAccountIDs', []) : [managerID, ownerAccountID];
- const participantAvatars = OptionsListUtils.getAvatarsForAccountIDs(participantAccountIDs, props.personalDetails);
- const sortedParticipantAvatars = _.sortBy(participantAvatars, (avatar) => avatar.id);
- if (isPolicyExpenseChat && props.isBillSplit) {
- sortedParticipantAvatars.push(ReportUtils.getWorkspaceIcon(props.chatReport));
+ const participantAccountIDs = action.actionName === CONST.REPORT.ACTIONS.TYPE.IOU && isBillSplit ? action.originalMessage.participantAccountIDs ?? [] : [managerID, ownerAccountID];
+ const participantAvatars = OptionsListUtils.getAvatarsForAccountIDs(participantAccountIDs, personalDetails ?? {});
+ const sortedParticipantAvatars = lodashSortBy(participantAvatars, (avatar) => avatar.id);
+ if (isPolicyExpenseChat && isBillSplit) {
+ sortedParticipantAvatars.push(ReportUtils.getWorkspaceIcon(chatReport));
}
// Pay button should only be visible to the manager of the report.
const isCurrentUserManager = managerID === sessionAccountID;
- const {amount: requestAmount, currency: requestCurrency, comment: requestComment, merchant} = ReportUtils.getTransactionDetails(props.transaction);
+ const {amount: requestAmount, currency: requestCurrency, comment: requestComment, merchant} = ReportUtils.getTransactionDetails(transaction) ?? {};
const description = truncate(requestComment, {length: CONST.REQUEST_PREVIEW.MAX_LENGTH});
const requestMerchant = truncate(merchant, {length: CONST.REQUEST_PREVIEW.MAX_LENGTH});
- const hasReceipt = TransactionUtils.hasReceipt(props.transaction);
- const isScanning = hasReceipt && TransactionUtils.isReceiptBeingScanned(props.transaction);
- const hasFieldErrors = TransactionUtils.hasMissingSmartscanFields(props.transaction);
- const isDistanceRequest = TransactionUtils.isDistanceRequest(props.transaction);
- const isExpensifyCardTransaction = TransactionUtils.isExpensifyCardTransaction(props.transaction);
- const isSettled = ReportUtils.isSettled(props.iouReport.reportID);
- const isDeleted = lodashGet(props.action, 'pendingAction', null) === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE;
+ const hasReceipt = TransactionUtils.hasReceipt(transaction);
+ const isScanning = hasReceipt && TransactionUtils.isReceiptBeingScanned(transaction);
+ const hasFieldErrors = TransactionUtils.hasMissingSmartscanFields(transaction);
+ const isDistanceRequest = TransactionUtils.isDistanceRequest(transaction);
+ const isCardTransaction = TransactionUtils.isCardTransaction(transaction);
+ const isSettled = ReportUtils.isSettled(iouReport?.reportID);
+ const isDeleted = action?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE;
// Show the merchant for IOUs and expenses only if they are custom or not related to scanning smartscan
- const shouldShowMerchant = !_.isEmpty(requestMerchant) && requestMerchant !== CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT && requestMerchant !== CONST.TRANSACTION.DEFAULT_MERCHANT;
- const shouldShowDescription = !_.isEmpty(description) && !shouldShowMerchant && !isScanning;
- const hasPendingWaypoints = lodashGet(props.transaction, 'pendingFields.waypoints', null);
+ const shouldShowMerchant = !!requestMerchant && requestMerchant !== CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT && requestMerchant !== CONST.TRANSACTION.DEFAULT_MERCHANT;
+ const shouldShowDescription = !!description && !shouldShowMerchant && !isScanning;
+ const hasPendingWaypoints = transaction?.pendingFields?.waypoints;
let merchantOrDescription = requestMerchant;
if (!shouldShowMerchant) {
@@ -181,20 +171,20 @@ function MoneyRequestPreview(props) {
merchantOrDescription = requestMerchant.replace(CONST.REGEX.FIRST_SPACE, translate('common.tbd'));
}
- const receiptImages = hasReceipt ? [ReceiptUtils.getThumbnailAndImageURIs(props.transaction)] : [];
+ const receiptImages = hasReceipt ? [ReceiptUtils.getThumbnailAndImageURIs(transaction)] : [];
- const getSettledMessage = () => {
- if (isExpensifyCardTransaction) {
+ const getSettledMessage = (): string => {
+ if (isCardTransaction) {
return translate('common.done');
}
return translate('iou.settledExpensify');
};
- const showContextMenu = (event) => {
- showContextMenuForReport(event, props.contextMenuAnchor, props.reportID, props.action, props.checkIfContextMenuActive);
+ const showContextMenu = (event: GestureResponderEvent) => {
+ showContextMenuForReport(event, contextMenuAnchor, reportID, action, checkIfContextMenuActive);
};
- const getPreviewHeaderText = () => {
+ const getPreviewHeaderText = (): string => {
if (isDistanceRequest) {
return translate('common.distance');
}
@@ -203,30 +193,30 @@ function MoneyRequestPreview(props) {
return translate('common.receipt');
}
- if (props.isBillSplit) {
+ if (isBillSplit) {
return translate('iou.split');
}
- if (isExpensifyCardTransaction) {
+ if (isCardTransaction) {
let message = translate('iou.card');
- if (TransactionUtils.isPending(props.transaction)) {
+ if (TransactionUtils.isPending(transaction)) {
message += ` • ${translate('iou.pending')}`;
}
return message;
}
let message = translate('iou.cash');
- if (ReportUtils.isPaidGroupPolicyExpenseReport(props.iouReport) && ReportUtils.isReportApproved(props.iouReport) && !ReportUtils.isSettled(props.iouReport)) {
+ if (ReportUtils.isPaidGroupPolicyExpenseReport(iouReport) && ReportUtils.isReportApproved(iouReport) && !ReportUtils.isSettled(iouReport?.reportID)) {
message += ` • ${translate('iou.approved')}`;
- } else if (props.iouReport.isWaitingOnBankAccount) {
+ } else if (iouReport?.isWaitingOnBankAccount) {
message += ` • ${translate('iou.pending')}`;
- } else if (props.iouReport.isCancelledIOU) {
+ } else if (iouReport?.isCancelledIOU) {
message += ` • ${translate('iou.canceled')}`;
}
return message;
};
- const getDisplayAmountText = () => {
+ const getDisplayAmountText = (): string => {
if (isDistanceRequest) {
return requestAmount && !hasPendingWaypoints ? CurrencyUtils.convertToDisplayString(requestAmount, requestCurrency) : translate('common.tbd');
}
@@ -235,19 +225,16 @@ function MoneyRequestPreview(props) {
return translate('iou.receiptScanning');
}
- if (!isSettled && TransactionUtils.hasMissingSmartscanFields(props.transaction)) {
+ if (!isSettled && TransactionUtils.hasMissingSmartscanFields(transaction)) {
return Localize.translateLocal('iou.receiptMissingDetails');
}
return CurrencyUtils.convertToDisplayString(requestAmount, requestCurrency);
};
- const getDisplayDeleteAmountText = () => {
- const {amount, currency} = ReportUtils.getTransactionDetails(props.action.originalMessage);
-
- if (isDistanceRequest) {
- return CurrencyUtils.convertToDisplayString(TransactionUtils.getAmount(props.action.originalMessage), currency);
- }
+ const getDisplayDeleteAmountText = (): string => {
+ const iouOriginalMessage: IOUMessage | EmptyObject = action?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? action.originalMessage : {};
+ const {amount = 0, currency = CONST.CURRENCY.USD} = iouOriginalMessage;
return CurrencyUtils.convertToDisplayString(amount, currency);
};
@@ -257,36 +244,34 @@ function MoneyRequestPreview(props) {
const childContainer = (
{
PaymentMethods.clearWalletTermsError();
- Report.clearIOUError(props.chatReportID);
+ Report.clearIOUError(chatReportID);
}}
errorRowStyles={[styles.mbn1]}
needsOffscreenAlphaCompositing
>
{hasReceipt && (
)}
- {_.isEmpty(props.transaction) &&
- !ReportActionsUtils.isMessageDeleted(props.action) &&
- lodashGet(props.action, 'pendingAction') !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE ? (
+ {isEmptyObject(transaction) && !ReportActionsUtils.isMessageDeleted(action) && action.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE ? (
) : (
- {getPreviewHeaderText() + (isSettled && !props.iouReport.isCancelledIOU ? ` • ${getSettledMessage()}` : '')}
+ {getPreviewHeaderText() + (isSettled && !iouReport?.isCancelledIOU ? ` • ${getSettledMessage()}` : '')}
{!isSettled && hasFieldErrors && (
{displayAmount}
- {ReportUtils.isSettled(props.iouReport.reportID) && !props.isBillSplit && (
+ {ReportUtils.isSettled(iouReport?.reportID) && !isBillSplit && (
)}
- {props.isBillSplit && (
+ {isBillSplit && (
@@ -331,17 +315,17 @@ function MoneyRequestPreview(props) {
- {!isCurrentUserManager && props.shouldShowPendingConversionMessage && (
+ {!isCurrentUserManager && shouldShowPendingConversionMessage && (
{translate('iou.pendingConversionMessage')}
)}
{shouldShowDescription && }
{shouldShowMerchant && {merchantOrDescription} }
- {props.isBillSplit && !_.isEmpty(participantAccountIDs) && requestAmount > 0 && (
+ {isBillSplit && participantAccountIDs.length > 0 && requestAmount && requestAmount > 0 && (
{translate('iou.amountEach', {
amount: CurrencyUtils.convertToDisplayString(
- IOUUtils.calculateAmount(isPolicyExpenseChat ? 1 : participantAccountIDs.length - 1, requestAmount, requestCurrency),
+ IOUUtils.calculateAmount(isPolicyExpenseChat ? 1 : participantAccountIDs.length - 1, requestAmount, requestCurrency ?? ''),
requestCurrency,
),
})}
@@ -355,32 +339,30 @@ function MoneyRequestPreview(props) {
);
- if (!props.onPreviewPressed) {
+ if (!onPreviewPressed) {
return childContainer;
}
- const shouldDisableOnPress = props.isBillSplit && _.isEmpty(props.transaction);
+ const shouldDisableOnPress = isBillSplit && isEmptyObject(transaction);
return (
DeviceCapabilities.canUseTouchScreen() && ControlSelection.block()}
onPressOut={() => ControlSelection.unblock()}
onLongPress={showContextMenu}
- accessibilityLabel={props.isBillSplit ? translate('iou.split') : translate('iou.cash')}
+ accessibilityLabel={isBillSplit ? translate('iou.split') : translate('iou.cash')}
accessibilityHint={CurrencyUtils.convertToDisplayString(requestAmount, requestCurrency)}
- style={[styles.moneyRequestPreviewBox, ...props.containerStyles, shouldDisableOnPress && styles.cursorDefault]}
+ style={[styles.moneyRequestPreviewBox, containerStyles, shouldDisableOnPress && styles.cursorDefault]}
>
{childContainer}
);
}
-MoneyRequestPreview.propTypes = propTypes;
-MoneyRequestPreview.defaultProps = defaultProps;
MoneyRequestPreview.displayName = 'MoneyRequestPreview';
-export default withOnyx({
+export default withOnyx({
personalDetails: {
key: ONYXKEYS.PERSONAL_DETAILS_LIST,
},
@@ -394,7 +376,10 @@ export default withOnyx({
key: ONYXKEYS.SESSION,
},
transaction: {
- key: ({action}) => `${ONYXKEYS.COLLECTION.TRANSACTION}${(action && action.originalMessage && action.originalMessage.IOUTransactionID) || 0}`,
+ key: ({action}) => {
+ const originalMessage = action?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? action.originalMessage : undefined;
+ return `${ONYXKEYS.COLLECTION.TRANSACTION}${originalMessage?.IOUTransactionID ?? 0}`;
+ },
},
walletTerms: {
key: ONYXKEYS.WALLET_TERMS,
diff --git a/src/components/ReportActionItem/MoneyRequestView.js b/src/components/ReportActionItem/MoneyRequestView.tsx
similarity index 71%
rename from src/components/ReportActionItem/MoneyRequestView.js
rename to src/components/ReportActionItem/MoneyRequestView.tsx
index e03dd6e4744c..3533506797bb 100644
--- a/src/components/ReportActionItem/MoneyRequestView.js
+++ b/src/components/ReportActionItem/MoneyRequestView.tsx
@@ -1,30 +1,24 @@
-import lodashGet from 'lodash/get';
-import lodashValues from 'lodash/values';
-import PropTypes from 'prop-types';
import React, {useCallback} from 'react';
import {View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
-import categoryPropTypes from '@components/categoryPropTypes';
+import type {OnyxEntry} from 'react-native-onyx';
import * as Expensicons from '@components/Icon/Expensicons';
import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription';
import OfflineWithFeedback from '@components/OfflineWithFeedback';
import ReceiptEmptyState from '@components/ReceiptEmptyState';
import SpacerView from '@components/SpacerView';
import Switch from '@components/Switch';
-import tagPropTypes from '@components/tagPropTypes';
import Text from '@components/Text';
-import transactionPropTypes from '@components/transactionPropTypes';
import ViolationMessages from '@components/ViolationMessages';
-import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsPropTypes} from '@components/withCurrentUserPersonalDetails';
import useLocalize from '@hooks/useLocalize';
import usePermissions from '@hooks/usePermissions';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import useViolations from '@hooks/useViolations';
+import type {ViolationField} from '@hooks/useViolations';
import useWindowDimensions from '@hooks/useWindowDimensions';
import * as CardUtils from '@libs/CardUtils';
-import compose from '@libs/compose';
import * as CurrencyUtils from '@libs/CurrencyUtils';
import Navigation from '@libs/Navigation/Navigation';
import * as OptionsListUtils from '@libs/OptionsListUtils';
@@ -34,97 +28,68 @@ import * as ReportActionsUtils from '@libs/ReportActionsUtils';
import * as ReportUtils from '@libs/ReportUtils';
import * as TransactionUtils from '@libs/TransactionUtils';
import AnimatedEmptyStateBackground from '@pages/home/report/AnimatedEmptyStateBackground';
-import reportActionPropTypes from '@pages/home/report/reportActionPropTypes';
-import iouReportPropTypes from '@pages/iouReportPropTypes';
-import reportPropTypes from '@pages/reportPropTypes';
-import {policyDefaultProps, policyPropTypes} from '@pages/workspace/withPolicy';
import * as IOU from '@userActions/IOU';
import * as Transaction from '@userActions/Transaction';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
+import type * as OnyxTypes from '@src/types/onyx';
+import type {TransactionPendingFieldsKey} from '@src/types/onyx/Transaction';
import ReportActionItemImage from './ReportActionItemImage';
-const violationNames = lodashValues(CONST.VIOLATIONS);
+type MoneyRequestViewTransactionOnyxProps = {
+ /** The transaction associated with the transactionThread */
+ transaction: OnyxEntry;
-const transactionViolationPropType = PropTypes.shape({
- type: PropTypes.string.isRequired,
- name: PropTypes.oneOf(violationNames).isRequired,
- data: PropTypes.shape({
- rejectedBy: PropTypes.string,
- rejectReason: PropTypes.string,
- amount: PropTypes.string,
- surcharge: PropTypes.number,
- invoiceMarkup: PropTypes.number,
- maxAge: PropTypes.number,
- tagName: PropTypes.string,
- formattedLimitAmount: PropTypes.string,
- categoryLimit: PropTypes.string,
- limit: PropTypes.string,
- category: PropTypes.string,
- brokenBankConnection: PropTypes.bool,
- isAdmin: PropTypes.bool,
- email: PropTypes.string,
- isTransactionOlderThan7Days: PropTypes.bool,
- member: PropTypes.string,
- taxName: PropTypes.string,
- }),
-});
+ /** Violations detected in this transaction */
+ transactionViolations: OnyxEntry;
+};
-const propTypes = {
- /** The report currently being looked at */
- report: reportPropTypes.isRequired,
+type MoneyRequestViewOnyxPropsWithoutTransaction = {
+ /** The policy object for the current route */
+ policy: OnyxEntry;
- /** Whether we should display the horizontal rule below the component */
- shouldShowHorizontalRule: PropTypes.bool.isRequired,
+ /** Collection of categories attached to a policy */
+ policyCategories: OnyxEntry;
+
+ /** Collection of tags attached to a policy */
+ policyTags: OnyxEntry;
- /* Onyx Props */
/** The expense report or iou report (only will have a value if this is a transaction thread) */
- parentReport: iouReportPropTypes,
+ parentReport: OnyxEntry;
/** The actions from the parent report */
- parentReportActions: PropTypes.objectOf(PropTypes.shape(reportActionPropTypes)),
-
- /** The policy the report is tied to */
- ...policyPropTypes,
-
- /** Collection of categories attached to a policy */
- policyCategories: PropTypes.objectOf(categoryPropTypes),
-
- /** The transaction associated with the transactionThread */
- transaction: transactionPropTypes,
-
- /** Violations detected in this transaction */
- transactionViolations: PropTypes.arrayOf(transactionViolationPropType),
+ parentReportActions: OnyxEntry;
+};
- /** Collection of tags attached to a policy */
- policyTags: tagPropTypes,
+type MoneyRequestViewPropsWithoutTransaction = MoneyRequestViewOnyxPropsWithoutTransaction & {
+ /** The report currently being looked at */
+ report: OnyxTypes.Report;
- ...withCurrentUserPersonalDetailsPropTypes,
+ /** Whether we should display the horizontal rule below the component */
+ shouldShowHorizontalRule: boolean;
};
-const defaultProps = {
- parentReport: {},
- parentReportActions: {},
- transaction: {
- amount: 0,
- currency: CONST.CURRENCY.USD,
- comment: {comment: ''},
- },
- transactionViolations: [],
- policyCategories: {},
- policyTags: {},
- ...policyDefaultProps,
-};
+type MoneyRequestViewProps = MoneyRequestViewTransactionOnyxProps & MoneyRequestViewPropsWithoutTransaction;
-function MoneyRequestView({report, parentReport, parentReportActions, policyCategories, shouldShowHorizontalRule, transaction, policyTags, policy, transactionViolations}) {
+function MoneyRequestView({
+ report,
+ parentReport,
+ parentReportActions,
+ policyCategories,
+ shouldShowHorizontalRule,
+ transaction,
+ policyTags,
+ policy,
+ transactionViolations,
+}: MoneyRequestViewProps) {
const theme = useTheme();
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
const {isSmallScreenWidth} = useWindowDimensions();
const {translate} = useLocalize();
const {canUseViolations} = usePermissions();
- const parentReportAction = parentReportActions[report.parentReportActionID] || {};
+ const parentReportAction = parentReportActions?.[report.parentReportActionID ?? ''] ?? null;
const moneyRequestReport = parentReport;
const {
created: transactionDate,
@@ -138,20 +103,20 @@ function MoneyRequestView({report, parentReport, parentReportActions, policyCate
originalAmount: transactionOriginalAmount,
originalCurrency: transactionOriginalCurrency,
cardID: transactionCardID,
- } = ReportUtils.getTransactionDetails(transaction);
+ } = ReportUtils.getTransactionDetails(transaction) ?? {};
const isEmptyMerchant = transactionMerchant === '' || transactionMerchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT;
const isDistanceRequest = TransactionUtils.isDistanceRequest(transaction);
let formattedTransactionAmount = transactionAmount ? CurrencyUtils.convertToDisplayString(transactionAmount, transactionCurrency) : '';
- const hasPendingWaypoints = lodashGet(transaction, 'pendingFields.waypoints', null);
+ const hasPendingWaypoints = transaction?.pendingFields?.waypoints;
if (isDistanceRequest && (!formattedTransactionAmount || hasPendingWaypoints)) {
formattedTransactionAmount = translate('common.tbd');
}
const formattedOriginalAmount = transactionOriginalAmount && transactionOriginalCurrency && CurrencyUtils.convertToDisplayString(transactionOriginalAmount, transactionOriginalCurrency);
const isCardTransaction = TransactionUtils.isCardTransaction(transaction);
- const cardProgramName = isCardTransaction ? CardUtils.getCardDescription(transactionCardID) : '';
+ const cardProgramName = isCardTransaction && transactionCardID !== undefined ? CardUtils.getCardDescription(transactionCardID) : '';
// Flags for allowing or disallowing editing a money request
- const isSettled = ReportUtils.isSettled(moneyRequestReport.reportID);
+ const isSettled = ReportUtils.isSettled(moneyRequestReport?.reportID);
const isCancelled = moneyRequestReport && moneyRequestReport.isCancelledIOU;
// Used for non-restricted fields such as: description, category, tag, billable, etc.
@@ -168,26 +133,30 @@ function MoneyRequestView({report, parentReport, parentReportActions, policyCate
// Fetches only the first tag, for now
const policyTag = PolicyUtils.getTag(policyTags);
- const policyTagsList = lodashGet(policyTag, 'tags', {});
+ const policyTagsList = policyTag?.tags ?? {};
// Flags for showing categories and tags
- const shouldShowCategory = isPolicyExpenseChat && (transactionCategory || OptionsListUtils.hasEnabledOptions(lodashValues(policyCategories)));
- const shouldShowTag = isPolicyExpenseChat && (transactionTag || OptionsListUtils.hasEnabledOptions(lodashValues(policyTagsList)));
- const shouldShowBillable = isPolicyExpenseChat && (transactionBillable || !lodashGet(policy, 'disabledFields.defaultBillable', true));
+ // transactionCategory can be an empty string
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ const shouldShowCategory = isPolicyExpenseChat && (transactionCategory || OptionsListUtils.hasEnabledOptions(Object.values(policyCategories ?? {})));
+ // transactionTag can be an empty string
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ const shouldShowTag = isPolicyExpenseChat && (transactionTag || OptionsListUtils.hasEnabledOptions(Object.values(policyTagsList)));
+ const shouldShowBillable = isPolicyExpenseChat && (!!transactionBillable || !(policy?.disabledFields?.defaultBillable ?? true));
- const {getViolationsForField} = useViolations(transactionViolations);
- const hasViolations = useCallback((field) => canUseViolations && getViolationsForField(field).length > 0, [canUseViolations, getViolationsForField]);
+ const {getViolationsForField} = useViolations(transactionViolations ?? []);
+ const hasViolations = useCallback((field: ViolationField): boolean => !!canUseViolations && getViolationsForField(field).length > 0, [canUseViolations, getViolationsForField]);
let amountDescription = `${translate('iou.amount')}`;
const saveBillable = useCallback(
- (newBillable) => {
+ (newBillable: boolean) => {
// If the value hasn't changed, don't request to save changes on the server and just close the modal
if (newBillable === TransactionUtils.getBillable(transaction)) {
Navigation.dismissModal();
return;
}
- IOU.updateMoneyRequestBillable(transaction.transactionID, report.reportID, newBillable);
+ IOU.updateMoneyRequestBillable(transaction?.transactionID ?? '', report?.reportID, newBillable);
Navigation.dismissModal();
},
[transaction, report],
@@ -226,8 +195,8 @@ function MoneyRequestView({report, parentReport, parentReportActions, policyCate
hasErrors = canEdit && TransactionUtils.hasMissingSmartscanFields(transaction);
}
- const pendingAction = lodashGet(transaction, 'pendingAction');
- const getPendingFieldAction = (fieldPath) => lodashGet(transaction, fieldPath) || pendingAction;
+ const pendingAction = transaction?.pendingAction;
+ const getPendingFieldAction = (fieldPath: TransactionPendingFieldsKey) => transaction?.pendingFields?.[fieldPath] ?? pendingAction;
return (
@@ -236,17 +205,22 @@ function MoneyRequestView({report, parentReport, parentReportActions, policyCate
{hasReceipt && (
{
+ if (!transaction?.transactionID) {
+ return;
+ }
+
Transaction.clearError(transaction.transactionID);
}}
>
)}
{canUseViolations && }
-
+
Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.AMOUNT))}
- brickRoadIndicator={hasViolations('amount') || (hasErrors && transactionAmount === 0) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''}
+ brickRoadIndicator={hasViolations('amount') || (hasErrors && transactionAmount === 0) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined}
error={hasErrors && transactionAmount === 0 ? translate('common.error.enterAmount') : ''}
/>
{canUseViolations && }
-
+
Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.DESCRIPTION))}
wrapperStyle={[styles.pv2, styles.taskDescriptionMenuItem]}
- brickRoadIndicator={hasViolations('comment') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''}
+ brickRoadIndicator={hasViolations('comment') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined}
numberOfLinesTitle={0}
/>
{canUseViolations && }
{isDistanceRequest ? (
-
+
) : (
-
+
Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.MERCHANT))}
- brickRoadIndicator={hasViolations('merchant') || (hasErrors && isEmptyMerchant && isPolicyExpenseChat) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''}
+ brickRoadIndicator={hasViolations('merchant') || (hasErrors && isEmptyMerchant && isPolicyExpenseChat) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined}
error={hasErrors && isPolicyExpenseChat && isEmptyMerchant ? translate('common.error.enterMerchant') : ''}
/>
{canUseViolations && }
)}
-
+
Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.DATE))}
- brickRoadIndicator={hasViolations('date') || (hasErrors && transactionDate === '') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''}
+ brickRoadIndicator={hasViolations('date') || (hasErrors && transactionDate === '') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined}
error={hasErrors && transactionDate === '' ? translate('common.error.enterDate') : ''}
/>
{canUseViolations && }
{shouldShowCategory && (
-
+
Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.CATEGORY))}
- brickRoadIndicator={hasViolations('category') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''}
+ brickRoadIndicator={hasViolations('category') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined}
/>
{canUseViolations && }
)}
{shouldShowTag && (
-
+
Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.TAG))}
- brickRoadIndicator={hasViolations('tag') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''}
+ brickRoadIndicator={hasViolations('tag') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined}
/>
{canUseViolations && }
)}
{isCardTransaction && (
-
+
{translate('common.billable')}
@@ -405,50 +379,42 @@ function MoneyRequestView({report, parentReport, parentReportActions, policyCate
);
}
-MoneyRequestView.propTypes = propTypes;
-MoneyRequestView.defaultProps = defaultProps;
MoneyRequestView.displayName = 'MoneyRequestView';
-export default compose(
- withCurrentUserPersonalDetails,
- withOnyx({
- session: {
- key: ONYXKEYS.SESSION,
- },
- policy: {
- key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`,
- },
- policyCategories: {
- key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${report.policyID}`,
- },
- policyTags: {
- key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY_TAGS}${report.policyID}`,
- },
- parentReport: {
- key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT}${report.parentReportID}`,
- },
- parentReportActions: {
- key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report ? report.parentReportID : '0'}`,
- canEvict: false,
- },
- }),
- withOnyx({
+export default withOnyx({
+ policy: {
+ key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`,
+ },
+ policyCategories: {
+ key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${report.policyID}`,
+ },
+ policyTags: {
+ key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY_TAGS}${report.policyID}`,
+ },
+ parentReport: {
+ key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT}${report.parentReportID}`,
+ },
+ parentReportActions: {
+ key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report ? report.parentReportID : '0'}`,
+ canEvict: false,
+ },
+})(
+ withOnyx({
transaction: {
key: ({report, parentReportActions}) => {
- const parentReportAction = lodashGet(parentReportActions, [report.parentReportActionID]);
- const transactionID = lodashGet(parentReportAction, ['originalMessage', 'IOUTransactionID'], 0);
+ const parentReportAction = parentReportActions?.[report.parentReportActionID ?? ''];
+ const originalMessage = parentReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? parentReportAction.originalMessage : undefined;
+ const transactionID = originalMessage?.IOUTransactionID ?? 0;
return `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`;
},
},
transactionViolations: {
key: ({report}) => {
const parentReportAction = ReportActionsUtils.getParentReportAction(report);
- const transactionID = lodashGet(parentReportAction, ['originalMessage', 'IOUTransactionID'], 0);
+ const originalMessage = parentReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? parentReportAction.originalMessage : undefined;
+ const transactionID = originalMessage?.IOUTransactionID ?? 0;
return `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`;
},
},
- policyTags: {
- key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY_TAGS}${report.policyID}`,
- },
- }),
-)(MoneyRequestView);
+ })(MoneyRequestView),
+);
diff --git a/src/components/ReportActionItem/ReportActionItemImage.tsx b/src/components/ReportActionItem/ReportActionItemImage.tsx
index e71eb6834cc7..04a99e00c6bf 100644
--- a/src/components/ReportActionItem/ReportActionItemImage.tsx
+++ b/src/components/ReportActionItem/ReportActionItemImage.tsx
@@ -3,6 +3,7 @@ import React from 'react';
import type {ReactElement} from 'react';
import type {ImageSourcePropType, ViewStyle} from 'react-native';
import {View} from 'react-native';
+import type {OnyxEntry} from 'react-native-onyx';
import AttachmentModal from '@components/AttachmentModal';
import EReceiptThumbnail from '@components/EReceiptThumbnail';
import Image from '@components/Image';
@@ -21,19 +22,22 @@ type ReportActionItemImageProps = {
thumbnail?: string | ImageSourcePropType | null;
/** URI for the image or local numeric reference for the image */
- image: string | ImageSourcePropType;
+ image?: string | ImageSourcePropType;
/** whether or not to enable the image preview modal */
enablePreviewModal?: boolean;
/* The transaction associated with this image, if any. Passed for handling eReceipts. */
- transaction?: Transaction;
+ transaction?: OnyxEntry;
/** whether thumbnail is refer the local file or not */
isLocalFile?: boolean;
/** whether the receipt can be replaced */
canEditReceipt?: boolean;
+
+ /** Filename of attachment */
+ filename?: string;
};
/**
@@ -42,7 +46,7 @@ type ReportActionItemImageProps = {
* and optional preview modal as well.
*/
-function ReportActionItemImage({thumbnail, image, enablePreviewModal = false, transaction, canEditReceipt = false, isLocalFile = false}: ReportActionItemImageProps) {
+function ReportActionItemImage({thumbnail, image, enablePreviewModal = false, transaction, canEditReceipt = false, isLocalFile = false, filename}: ReportActionItemImageProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const imageSource = tryResolveUrlFromApiRoot(image ?? '');
@@ -86,7 +90,7 @@ function ReportActionItemImage({thumbnail, image, enablePreviewModal = false, tr
isReceiptAttachment
canEditReceipt={canEditReceipt}
allowDownload
- originalFileName={transaction?.filename}
+ originalFileName={filename}
>
{({show}) => (
+
{
diff --git a/src/components/ReportActionItem/TaskPreview.tsx b/src/components/ReportActionItem/TaskPreview.tsx
index 16af68d9677c..5fc75d423a67 100644
--- a/src/components/ReportActionItem/TaskPreview.tsx
+++ b/src/components/ReportActionItem/TaskPreview.tsx
@@ -28,22 +28,14 @@ import * as Task from '@userActions/Task';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
-import type {Policy, Report, ReportAction} from '@src/types/onyx';
+import type {Report, ReportAction} from '@src/types/onyx';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
-type PolicyRole = {
- /** The role of current user */
- role: Task.PolicyValue | undefined;
-};
-
type TaskPreviewOnyxProps = {
/* Onyx Props */
/* current report of TaskPreview */
taskReport: OnyxEntry;
-
- /** The policy of root parent report */
- rootParentReportpolicy: OnyxEntry;
};
type TaskPreviewProps = WithCurrentUserPersonalDetailsProps &
@@ -70,17 +62,7 @@ type TaskPreviewProps = WithCurrentUserPersonalDetailsProps &
checkIfContextMenuActive: () => void;
};
-function TaskPreview({
- taskReport,
- taskReportID,
- action,
- contextMenuAnchor,
- chatReportID,
- checkIfContextMenuActive,
- currentUserPersonalDetails,
- rootParentReportpolicy,
- isHovered = false,
-}: TaskPreviewProps) {
+function TaskPreview({taskReport, taskReportID, action, contextMenuAnchor, chatReportID, checkIfContextMenuActive, currentUserPersonalDetails, isHovered = false}: TaskPreviewProps) {
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT;
@@ -123,7 +105,7 @@ function TaskPreview({
style={[styles.mr2]}
containerStyle={[styles.taskCheckbox]}
isChecked={isTaskCompleted}
- disabled={!Task.canModifyTask(taskReport, currentUserPersonalDetails.accountID, rootParentReportpolicy?.role)}
+ disabled={!Task.canModifyTask(taskReport, currentUserPersonalDetails.accountID)}
onPress={Session.checkIfActionIsAllowed(() => {
if (isTaskCompleted) {
Task.reopenTask(taskReport);
@@ -151,9 +133,5 @@ export default withCurrentUserPersonalDetails(
taskReport: {
key: ({taskReportID}) => `${ONYXKEYS.COLLECTION.REPORT}${taskReportID}`,
},
- rootParentReportpolicy: {
- key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY}${policyID ?? '0'}`,
- selector: (policy: Policy | null) => ({role: policy?.role}),
- },
})(TaskPreview),
);
diff --git a/src/components/ReportActionItem/TaskView.tsx b/src/components/ReportActionItem/TaskView.tsx
index ea685d07579d..bdcc4d01b1a8 100644
--- a/src/components/ReportActionItem/TaskView.tsx
+++ b/src/components/ReportActionItem/TaskView.tsx
@@ -28,14 +28,11 @@ import * as Task from '@userActions/Task';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
-import type {PersonalDetailsList, Policy, Report} from '@src/types/onyx';
+import type {PersonalDetailsList, Report} from '@src/types/onyx';
type TaskViewOnyxProps = {
/** All of the personal details for everyone */
personalDetails: OnyxEntry;
-
- /** The policy for the current route */
- policy: Pick | null;
};
type TaskViewProps = TaskViewOnyxProps &
@@ -47,7 +44,7 @@ type TaskViewProps = TaskViewOnyxProps &
shouldShowHorizontalRule: boolean;
};
-function TaskView({report, policy, shouldShowHorizontalRule, ...props}: TaskViewProps) {
+function TaskView({report, shouldShowHorizontalRule, ...props}: TaskViewProps) {
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
useEffect(() => {
@@ -61,7 +58,7 @@ function TaskView({report, policy, shouldShowHorizontalRule, ...props}: TaskView
);
const isCompleted = ReportUtils.isCompletedTaskReport(report);
const isOpen = ReportUtils.isOpenTaskReport(report);
- const canModifyTask = Task.canModifyTask(report, props.currentUserPersonalDetails.accountID, policy?.role);
+ const canModifyTask = Task.canModifyTask(report, props.currentUserPersonalDetails.accountID);
const disableState = !canModifyTask;
const isDisableInteractive = !canModifyTask || !isOpen;
const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT;
@@ -199,13 +196,6 @@ const TaskViewWithOnyx = withOnyx({
personalDetails: {
key: ONYXKEYS.PERSONAL_DETAILS_LIST,
},
- policy: {
- key: ({report}) => {
- const rootParentReport = ReportUtils.getRootParentReport(report);
- return `${ONYXKEYS.COLLECTION.POLICY}${rootParentReport ? rootParentReport.policyID : '0'}`;
- },
- selector: (policy: OnyxEntry) => (policy ? {role: policy.role} : null),
- },
})(TaskView);
export default withCurrentUserPersonalDetails(TaskViewWithOnyx);
diff --git a/src/components/ReportHeaderSkeletonView.tsx b/src/components/ReportHeaderSkeletonView.tsx
index 8b8bb721a5dc..2113abd85e88 100644
--- a/src/components/ReportHeaderSkeletonView.tsx
+++ b/src/components/ReportHeaderSkeletonView.tsx
@@ -25,7 +25,7 @@ function ReportHeaderSkeletonView({shouldAnimate = true, onBackButtonPress = ()
return (
-
+
{isSmallScreenWidth && (
diff --git a/src/components/ScreenWrapper.tsx b/src/components/ScreenWrapper.tsx
index 0653e2ff8577..a7d05a335d43 100644
--- a/src/components/ScreenWrapper.tsx
+++ b/src/components/ScreenWrapper.tsx
@@ -86,6 +86,9 @@ type ScreenWrapperProps = {
* This is required because transitionEnd event doesn't trigger in the testing environment.
*/
navigation?: StackNavigationProp;
+
+ /** Whether to show offline indicator on wide screens */
+ shouldShowOfflineIndicatorInWideScreen?: boolean;
};
function ScreenWrapper(
@@ -106,6 +109,7 @@ function ScreenWrapper(
onEntryTransitionEnd,
testID,
navigation: navigationProp,
+ shouldShowOfflineIndicatorInWideScreen = false,
}: ScreenWrapperProps,
ref: ForwardedRef,
) {
@@ -238,6 +242,12 @@ function ScreenWrapper(
: children
}
{isSmallScreenWidth && shouldShowOfflineIndicator && }
+ {!isSmallScreenWidth && shouldShowOfflineIndicatorInWideScreen && (
+
+ )}
diff --git a/src/components/Search.tsx b/src/components/Search.tsx
index 0b02c5dc5b5b..38135fd2631e 100644
--- a/src/components/Search.tsx
+++ b/src/components/Search.tsx
@@ -2,6 +2,7 @@ import React from 'react';
import type {GestureResponderEvent, StyleProp, ViewStyle} from 'react-native';
import {View} from 'react-native';
import useLocalize from '@hooks/useLocalize';
+import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import variables from '@styles/variables';
import CONST from '@src/CONST';
@@ -23,37 +24,44 @@ type SearchProps = {
// Styles to apply on the outer element
style?: StyleProp;
+
+ /** Styles to apply to the outermost element */
+ containerStyle?: StyleProp;
};
-function Search({onPress, placeholder, tooltip, style}: SearchProps) {
+function Search({onPress, placeholder, tooltip, style, containerStyle}: SearchProps) {
const styles = useThemeStyles();
+ const theme = useTheme();
const {translate} = useLocalize();
return (
-
-
- {({hovered}) => (
-
-
-
- {placeholder ?? translate('common.searchWithThreeDots')}
-
-
- )}
-
-
+
+
+
+ {({hovered}) => (
+
+
+
+ {placeholder ?? translate('common.searchWithThreeDots')}
+
+
+ )}
+
+
+
);
}
diff --git a/src/components/Section/index.tsx b/src/components/Section/index.tsx
index f24316a5f1bb..58e89d5bff76 100644
--- a/src/components/Section/index.tsx
+++ b/src/components/Section/index.tsx
@@ -2,10 +2,15 @@ import React from 'react';
import type {StyleProp, ViewStyle} from 'react-native';
import {View} from 'react-native';
import type {ValueOf} from 'type-fest';
+import Lottie from '@components/Lottie';
+import type DotLottieAnimation from '@components/LottieAnimations/types';
import type {MenuItemWithLink} from '@components/MenuItemList';
import MenuItemList from '@components/MenuItemList';
import Text from '@components/Text';
+import useStyleUtils from '@hooks/useStyleUtils';
+import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
+import useWindowDimensions from '@hooks/useWindowDimensions';
import type ChildrenProps from '@src/types/utils/ChildrenProps';
import type IconAsset from '@src/types/utils/IconAsset';
import IconSection from './IconSection';
@@ -48,6 +53,18 @@ type SectionProps = ChildrenProps & {
/** Customize the Icon container */
iconContainerStyles?: StyleProp;
+
+ /** Whether the section is in the central pane of the layout */
+ isCentralPane?: boolean;
+
+ /** The illustration to display in the header. Can be a JSON object representing a Lottie animation. */
+ illustration?: DotLottieAnimation;
+
+ /** The background color to apply in the upper half of the screen. */
+ illustrationBackgroundColor?: string;
+
+ /** Styles to apply to illustration component */
+ illustrationStyle?: StyleProp;
};
function Section({
@@ -63,39 +80,62 @@ function Section({
subtitleMuted = false,
title,
titleStyles,
+ isCentralPane = false,
+ illustration,
+ illustrationBackgroundColor,
+ illustrationStyle,
}: SectionProps) {
const styles = useThemeStyles();
+ const theme = useTheme();
+ const StyleUtils = useStyleUtils();
+ const {isSmallScreenWidth} = useWindowDimensions();
+
+ const illustrationContainerStyle: StyleProp = StyleUtils.getBackgroundColorStyle(illustrationBackgroundColor ?? theme.appBG);
return (
<>
-
+
{cardLayout === CARD_LAYOUT.ICON_ON_TOP && (
)}
-
-
- {title}
-
- {cardLayout === CARD_LAYOUT.ICON_ON_RIGHT && (
-
- )}
-
-
- {!!subtitle && (
-
- {subtitle}
+ {!!illustration && (
+
+
+
+
)}
+
+
+
+ {title}
+
+ {cardLayout === CARD_LAYOUT.ICON_ON_RIGHT && (
+
+ )}
+
- {children}
+ {!!subtitle && (
+
+ {subtitle}
+
+ )}
+
+ {children}
- {!!menuItems && }
+ {!!menuItems && }
+
>
);
diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx
index f3f609590101..aa5b15973b9f 100644
--- a/src/components/SelectionList/BaseSelectionList.tsx
+++ b/src/components/SelectionList/BaseSelectionList.tsx
@@ -273,7 +273,7 @@ function BaseSelectionList(
// we need to know the heights of all list items up-front in order to synchronously compute the layout of any given list item.
// So be aware that if you adjust the content of the section header (for example, change the font size), you may need to adjust this explicit height as well.
- {section.title}
+ {section.title}
);
};
@@ -386,7 +386,7 @@ function BaseSelectionList(
{({safeAreaPaddingBottomStyle}) => (
{shouldShowTextInput && (
-
+
{
textInputRef.current = element as RNTextInput;
@@ -427,7 +427,7 @@ function BaseSelectionList(
<>
{!headerMessage && canSelectMultiple && shouldShowSelectAll && (
)}
-
+
;
@@ -22,6 +38,9 @@ type SubscriptAvatarProps = {
/** Background color used for subscript avatar border */
backgroundColor?: string;
+ /** Subscript icon */
+ subscriptIcon?: SubIcon;
+
/** Removes margin from around the avatar, used for the chat view */
noMargin?: boolean;
@@ -29,7 +48,7 @@ type SubscriptAvatarProps = {
showTooltip?: boolean;
};
-function SubscriptAvatar({mainAvatar, secondaryAvatar, size = CONST.AVATAR_SIZE.DEFAULT, backgroundColor, noMargin = false, showTooltip = true}: SubscriptAvatarProps) {
+function SubscriptAvatar({mainAvatar, secondaryAvatar, subscriptIcon, size = CONST.AVATAR_SIZE.DEFAULT, backgroundColor, noMargin = false, showTooltip = true}: SubscriptAvatarProps) {
const theme = useTheme();
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
@@ -55,30 +74,59 @@ function SubscriptAvatar({mainAvatar, secondaryAvatar, size = CONST.AVATAR_SIZE.
/>
-
+ {secondaryAvatar && (
+
+
+
+
+
+ )}
+ {subscriptIcon && (
-
-
+ )}
);
}
diff --git a/src/components/TaskHeaderActionButton.tsx b/src/components/TaskHeaderActionButton.tsx
index d39f03c5aad4..2d964f58c253 100644
--- a/src/components/TaskHeaderActionButton.tsx
+++ b/src/components/TaskHeaderActionButton.tsx
@@ -14,9 +14,6 @@ import Button from './Button';
type TaskHeaderActionButtonOnyxProps = {
/** Current user session */
session: OnyxEntry;
-
- /** The policy of root parent report */
- policy: OnyxEntry;
};
type TaskHeaderActionButtonProps = TaskHeaderActionButtonOnyxProps & {
@@ -24,7 +21,7 @@ type TaskHeaderActionButtonProps = TaskHeaderActionButtonOnyxProps & {
report: OnyxTypes.Report;
};
-function TaskHeaderActionButton({report, session, policy}: TaskHeaderActionButtonProps) {
+function TaskHeaderActionButton({report, session}: TaskHeaderActionButtonProps) {
const {translate} = useLocalize();
const styles = useThemeStyles();
@@ -32,7 +29,7 @@ function TaskHeaderActionButton({report, session, policy}: TaskHeaderActionButto
(ReportUtils.isCompletedTaskReport(report) ? Task.reopenTask(report) : Task.completeTask(report)))}
@@ -48,10 +45,4 @@ export default withOnyx {
- const rootParentReport = ReportUtils.getRootParentReport(report);
- return `${ONYXKEYS.COLLECTION.POLICY}${rootParentReport ? rootParentReport.policyID : '0'}`;
- },
- },
})(TaskHeaderActionButton);
diff --git a/src/components/TextInput/BaseTextInput/index.native.tsx b/src/components/TextInput/BaseTextInput/index.native.tsx
index 4b764fc5baad..c9b46ac0b6cb 100644
--- a/src/components/TextInput/BaseTextInput/index.native.tsx
+++ b/src/components/TextInput/BaseTextInput/index.native.tsx
@@ -35,6 +35,7 @@ function BaseTextInput(
defaultValue = undefined,
placeholder = '',
errorText = '',
+ iconLeft = null,
icon = null,
textInputContainerStyles,
touchableInputWrapperStyle,
@@ -303,6 +304,16 @@ function BaseTextInput(
>
) : null}
+ {iconLeft && (
+
+
+
+ )}
{!!prefixCharacter && (
) : null}
+ {iconLeft && (
+
+
+
+ )}
{Boolean(prefixCharacter) && (
;
diff --git a/src/components/WalletSection.tsx b/src/components/WalletSection.tsx
deleted file mode 100644
index 0c966367be45..000000000000
--- a/src/components/WalletSection.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-import React from 'react';
-import useThemeStyles from '@hooks/useThemeStyles';
-import type ChildrenProps from '@src/types/utils/ChildrenProps';
-import type IconAsset from '@src/types/utils/IconAsset';
-import Section from './Section';
-
-type WalletSectionProps = ChildrenProps & {
- /** The icon to display along with the title */
- icon: IconAsset;
-
- /** The text to display in the subtitle of the section */
- subtitle: string;
-
- /** The text to display in the title of the section */
- title: string;
-};
-
-function WalletSection({children, icon, subtitle, title}: WalletSectionProps) {
- const styles = useThemeStyles();
- return (
-
- );
-}
-
-WalletSection.displayName = 'WalletSection';
-
-export default WalletSection;
diff --git a/src/components/WorkspaceSwitcherButton.tsx b/src/components/WorkspaceSwitcherButton.tsx
new file mode 100644
index 000000000000..b7485fbab7a8
--- /dev/null
+++ b/src/components/WorkspaceSwitcherButton.tsx
@@ -0,0 +1,74 @@
+import React, {useMemo} from 'react';
+import type {OnyxEntry} from 'react-native-onyx';
+import {withOnyx} from 'react-native-onyx';
+import useLocalize from '@hooks/useLocalize';
+import useTheme from '@hooks/useTheme';
+import interceptAnonymousUser from '@libs/interceptAnonymousUser';
+import Navigation from '@libs/Navigation/Navigation';
+import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import ROUTES from '@src/ROUTES';
+import type {Policy} from '@src/types/onyx';
+import * as Expensicons from './Icon/Expensicons';
+import {PressableWithFeedback} from './Pressable';
+import SubscriptAvatar from './SubscriptAvatar';
+
+type WorkspaceSwitcherButtonOnyxProps = {
+ policy: OnyxEntry;
+};
+
+type WorkspaceSwitcherButtonProps = {activeWorkspaceID?: string} & WorkspaceSwitcherButtonOnyxProps;
+
+function WorkspaceSwitcherButton({activeWorkspaceID, policy}: WorkspaceSwitcherButtonProps) {
+ const {translate} = useLocalize();
+ const theme = useTheme();
+
+ const {source, name, type} = useMemo(() => {
+ if (!activeWorkspaceID) {
+ return {source: Expensicons.ExpensifyAppIcon, name: CONST.WORKSPACE_SWITCHER.NAME, type: CONST.ICON_TYPE_AVATAR};
+ }
+
+ const avatar = policy?.avatar ? policy.avatar : getDefaultWorkspaceAvatar(policy?.name);
+ return {
+ source: avatar,
+ name: policy?.name ?? '',
+ type: CONST.ICON_TYPE_WORKSPACE,
+ };
+ }, [policy, activeWorkspaceID]);
+
+ return (
+
+ interceptAnonymousUser(() => {
+ Navigation.navigate(ROUTES.WORKSPACE_SWITCHER);
+ })
+ }
+ >
+ {({hovered}) => (
+
+ )}
+
+ );
+}
+
+WorkspaceSwitcherButton.displayName = 'WorkspaceSwitcherButton';
+
+export default withOnyx({
+ policy: {
+ key: ({activeWorkspaceID}) => `${ONYXKEYS.COLLECTION.POLICY}${activeWorkspaceID}`,
+ },
+})(WorkspaceSwitcherButton);
diff --git a/src/components/menuItemPropTypes.js b/src/components/menuItemPropTypes.js
index 83d2feca7a0a..3b21cd13c848 100644
--- a/src/components/menuItemPropTypes.js
+++ b/src/components/menuItemPropTypes.js
@@ -169,6 +169,9 @@ const propTypes = {
/** Icon should be displayed in its own color */
displayInDefaultIconColor: PropTypes.bool,
+
+ /** Is this menu item in the settings pane */
+ isPaneMenu: PropTypes.bool,
};
export default propTypes;
diff --git a/src/hooks/useActiveRoute.ts b/src/hooks/useActiveRoute.ts
new file mode 100644
index 000000000000..651d00a0c37c
--- /dev/null
+++ b/src/hooks/useActiveRoute.ts
@@ -0,0 +1,8 @@
+import {useContext} from 'react';
+import ActiveRouteContext from '@libs/Navigation/AppNavigator/Navigators/ActiveRouteContext';
+
+function useActiveRoute(): string {
+ return useContext(ActiveRouteContext);
+}
+
+export default useActiveRoute;
diff --git a/src/hooks/useActiveWorkspace.ts b/src/hooks/useActiveWorkspace.ts
new file mode 100644
index 000000000000..cce3c2a4b31f
--- /dev/null
+++ b/src/hooks/useActiveWorkspace.ts
@@ -0,0 +1,9 @@
+import {useContext} from 'react';
+import ActiveWorkspaceContext from '@components/ActiveWorkspace/ActiveWorkspaceContext';
+import type {ActiveWorkspaceContextType} from '@components/ActiveWorkspace/ActiveWorkspaceContext';
+
+function useActiveWorkspace(): ActiveWorkspaceContextType {
+ return useContext(ActiveWorkspaceContext);
+}
+
+export default useActiveWorkspace;
diff --git a/src/hooks/useArrowKeyFocusManager.ts b/src/hooks/useArrowKeyFocusManager.ts
index ecd494dfd9ea..352734c92e8d 100644
--- a/src/hooks/useArrowKeyFocusManager.ts
+++ b/src/hooks/useArrowKeyFocusManager.ts
@@ -9,6 +9,8 @@ type Config = {
disabledIndexes?: readonly number[];
shouldExcludeTextAreaNodes?: boolean;
isActive?: boolean;
+ itemsPerRow?: number;
+ disableCyclicTraversal?: boolean;
};
type UseArrowKeyFocusManager = [number, (index: number) => void];
@@ -24,6 +26,8 @@ type UseArrowKeyFocusManager = [number, (index: number) => void];
* @param [config.disabledIndexes] – An array of indexes to disable + skip over
* @param [config.shouldExcludeTextAreaNodes] – Whether arrow keys should have any effect when a TextArea node is focused
* @param [config.isActive] – Whether the component is ready and should subscribe to KeyboardShortcut
+ * @param [config.itemsPerRow] – The number of items per row. If provided, the arrow keys will move focus horizontally as well as vertically
+ * @param [config.disableCyclicTraversal] – Whether to disable cyclic traversal of the list. If true, the arrow keys will have no effect when the first or last item is focused
*/
export default function useArrowKeyFocusManager({
maxIndex,
@@ -35,7 +39,10 @@ export default function useArrowKeyFocusManager({
disabledIndexes = CONST.EMPTY_ARRAY,
shouldExcludeTextAreaNodes = true,
isActive,
+ itemsPerRow,
+ disableCyclicTraversal = false,
}: Config): UseArrowKeyFocusManager {
+ const allowHorizontalArrowKeys = !!itemsPerRow;
const [focusedIndex, setFocusedIndex] = useState(initialFocusedIndex);
const arrowConfig = useMemo(
() => ({
@@ -45,19 +52,29 @@ export default function useArrowKeyFocusManager({
[isActive, shouldExcludeTextAreaNodes],
);
- useEffect(() => onFocusedIndexChange(focusedIndex), [focusedIndex, onFocusedIndexChange]);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ useEffect(() => onFocusedIndexChange(focusedIndex), [focusedIndex]);
const arrowUpCallback = useCallback(() => {
if (maxIndex < 0) {
return;
}
+ const nextIndex = disableCyclicTraversal ? -1 : maxIndex;
setFocusedIndex((actualIndex) => {
- const currentFocusedIndex = actualIndex > 0 ? actualIndex - 1 : maxIndex;
+ let currentFocusedIndex = -1;
+ if (allowHorizontalArrowKeys) {
+ currentFocusedIndex = actualIndex > 0 ? actualIndex - itemsPerRow : nextIndex;
+ } else {
+ currentFocusedIndex = actualIndex > 0 ? actualIndex - 1 : nextIndex;
+ }
let newFocusedIndex = currentFocusedIndex;
while (disabledIndexes.includes(newFocusedIndex)) {
- newFocusedIndex = newFocusedIndex > 0 ? newFocusedIndex - 1 : maxIndex;
+ newFocusedIndex -= allowHorizontalArrowKeys ? itemsPerRow : 1;
+ if (newFocusedIndex < 0) {
+ break;
+ }
if (newFocusedIndex === currentFocusedIndex) {
// all indexes are disabled
return actualIndex; // no-op
@@ -65,7 +82,8 @@ export default function useArrowKeyFocusManager({
}
return newFocusedIndex;
});
- }, [disabledIndexes, maxIndex]);
+ }, [allowHorizontalArrowKeys, disableCyclicTraversal, disabledIndexes, itemsPerRow, maxIndex]);
+
useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ARROW_UP, arrowUpCallback, arrowConfig);
const arrowDownCallback = useCallback(() => {
@@ -73,23 +91,99 @@ export default function useArrowKeyFocusManager({
return;
}
+ const nextIndex = disableCyclicTraversal ? maxIndex : 0;
+
setFocusedIndex((actualIndex) => {
- const currentFocusedIndex = actualIndex < maxIndex ? actualIndex + 1 : 0;
- let newFocusedIndex = currentFocusedIndex;
+ let currentFocusedIndex = -1;
+
+ if (actualIndex === -1) {
+ currentFocusedIndex = 0;
+ } else if (allowHorizontalArrowKeys) {
+ currentFocusedIndex = actualIndex < maxIndex ? actualIndex + itemsPerRow : nextIndex;
+ } else {
+ currentFocusedIndex = actualIndex < maxIndex ? actualIndex + 1 : nextIndex;
+ }
+ if (disableCyclicTraversal && currentFocusedIndex > maxIndex) {
+ return actualIndex;
+ }
+
+ let newFocusedIndex = currentFocusedIndex;
while (disabledIndexes.includes(newFocusedIndex)) {
- newFocusedIndex = newFocusedIndex < maxIndex ? newFocusedIndex + 1 : 0;
+ if (actualIndex < 0) {
+ newFocusedIndex += 1;
+ } else {
+ newFocusedIndex += allowHorizontalArrowKeys ? itemsPerRow : 1;
+ }
+
+ if (newFocusedIndex < 0) {
+ break;
+ }
if (newFocusedIndex === currentFocusedIndex) {
// all indexes are disabled
return actualIndex;
}
}
-
return newFocusedIndex;
});
- }, [disabledIndexes, maxIndex]);
+ }, [allowHorizontalArrowKeys, disableCyclicTraversal, disabledIndexes, itemsPerRow, maxIndex]);
+
useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ARROW_DOWN, arrowDownCallback, arrowConfig);
+ const arrowLeftCallback = useCallback(() => {
+ if (maxIndex < 0 || !allowHorizontalArrowKeys) {
+ return;
+ }
+
+ const nextIndex = disableCyclicTraversal ? -1 : maxIndex;
+
+ setFocusedIndex((actualIndex) => {
+ let currentFocusedIndex = -1;
+ currentFocusedIndex = actualIndex > 0 ? actualIndex - 1 : nextIndex;
+
+ let newFocusedIndex = currentFocusedIndex;
+
+ while (disabledIndexes.includes(newFocusedIndex)) {
+ newFocusedIndex = newFocusedIndex > 0 ? newFocusedIndex - 1 : nextIndex;
+
+ if (newFocusedIndex === currentFocusedIndex) {
+ // all indexes are disabled
+ return actualIndex; // no-op
+ }
+ }
+ return newFocusedIndex;
+ });
+ }, [allowHorizontalArrowKeys, disableCyclicTraversal, disabledIndexes, maxIndex]);
+
+ useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ARROW_LEFT, arrowLeftCallback, arrowConfig);
+
+ const arrowRightCallback = useCallback(() => {
+ if (maxIndex < 0 || !allowHorizontalArrowKeys) {
+ return;
+ }
+
+ const nextIndex = disableCyclicTraversal ? maxIndex : 0;
+
+ setFocusedIndex((actualIndex) => {
+ let currentFocusedIndex = -1;
+ currentFocusedIndex = actualIndex < maxIndex ? actualIndex + 1 : nextIndex;
+
+ let newFocusedIndex = currentFocusedIndex;
+
+ while (disabledIndexes.includes(newFocusedIndex)) {
+ newFocusedIndex = newFocusedIndex < maxIndex ? newFocusedIndex + 1 : nextIndex;
+
+ if (newFocusedIndex === currentFocusedIndex) {
+ // all indexes are disabled
+ return actualIndex;
+ }
+ }
+ return newFocusedIndex;
+ });
+ }, [allowHorizontalArrowKeys, disableCyclicTraversal, disabledIndexes, maxIndex]);
+
+ useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ARROW_RIGHT, arrowRightCallback, arrowConfig);
+
// Note: you don't need to manually manage focusedIndex in the parent. setFocusedIndex is only exposed in case you want to reset focusedIndex or focus a specific item
return [focusedIndex, setFocusedIndex];
}
diff --git a/src/hooks/useAutoFocusInput.ts b/src/hooks/useAutoFocusInput.ts
index 20d0dee1f407..e8e7c1187a63 100644
--- a/src/hooks/useAutoFocusInput.ts
+++ b/src/hooks/useAutoFocusInput.ts
@@ -23,10 +23,14 @@ export default function useAutoFocusInput(): UseAutoFocusInput {
if (!isScreenTransitionEnded || !isInputInitialized || !inputRef.current || !isSplashHidden) {
return;
}
- InteractionManager.runAfterInteractions(() => {
+ const focusTaskHandle = InteractionManager.runAfterInteractions(() => {
inputRef.current?.focus();
setIsScreenTransitionEnded(false);
});
+
+ return () => {
+ focusTaskHandle.cancel();
+ };
}, [isScreenTransitionEnded, isInputInitialized, isSplashHidden]);
useFocusEffect(
@@ -35,12 +39,12 @@ export default function useAutoFocusInput(): UseAutoFocusInput {
setIsScreenTransitionEnded(true);
}, CONST.ANIMATED_TRANSITION);
return () => {
+ setIsScreenTransitionEnded(false);
if (!focusTimeoutRef.current) {
return;
}
clearTimeout(focusTimeoutRef.current);
};
- // eslint-disable-next-line react-hooks/exhaustive-deps
}, []),
);
diff --git a/src/hooks/useHtmlPaste/index.native.ts b/src/hooks/useHtmlPaste/index.native.ts
new file mode 100644
index 000000000000..be265bd153a7
--- /dev/null
+++ b/src/hooks/useHtmlPaste/index.native.ts
@@ -0,0 +1,4 @@
+import type UseHtmlPaste from './types';
+
+const useHtmlPaste: UseHtmlPaste = () => {};
+export default useHtmlPaste;
diff --git a/src/hooks/useHtmlPaste/index.ts b/src/hooks/useHtmlPaste/index.ts
new file mode 100644
index 000000000000..bdd13c2ceff8
--- /dev/null
+++ b/src/hooks/useHtmlPaste/index.ts
@@ -0,0 +1,123 @@
+import {useNavigation} from '@react-navigation/native';
+import ExpensiMark from 'expensify-common/lib/ExpensiMark';
+import {useCallback, useEffect} from 'react';
+import type UseHtmlPaste from './types';
+
+const useHtmlPaste: UseHtmlPaste = (textInputRef, preHtmlPasteCallback, removeListenerOnScreenBlur = false) => {
+ const navigation = useNavigation();
+
+ /**
+ * Set pasted text to clipboard
+ * @param {String} text
+ */
+ const paste = useCallback((text: string) => {
+ try {
+ document.execCommand('insertText', false, text);
+
+ // Pointer will go out of sight when a large paragraph is pasted on the web. Refocusing the input keeps the cursor in view.
+ textInputRef.current?.blur();
+ textInputRef.current?.focus();
+ // eslint-disable-next-line no-empty
+ } catch (e) {}
+ // We only need to set the callback once.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ /**
+ * Manually place the pasted HTML into Composer
+ *
+ * @param {String} html - pasted HTML
+ */
+ const handlePastedHTML = useCallback(
+ (html: string) => {
+ const parser = new ExpensiMark();
+ paste(parser.htmlToMarkdown(html));
+ },
+ [paste],
+ );
+
+ /**
+ * Paste the plaintext content into Composer.
+ *
+ * @param {ClipboardEvent} event
+ */
+ const handlePastePlainText = useCallback(
+ (event: ClipboardEvent) => {
+ const plainText = event.clipboardData?.getData('text/plain');
+ if (plainText) {
+ paste(plainText);
+ }
+ },
+ [paste],
+ );
+
+ const handlePaste = useCallback(
+ (event: ClipboardEvent) => {
+ if (!textInputRef.current) {
+ return;
+ }
+
+ if (preHtmlPasteCallback?.(event)) {
+ return;
+ }
+
+ const isFocused = textInputRef.current?.isFocused();
+
+ if (!isFocused) {
+ return;
+ }
+
+ event.preventDefault();
+
+ const types = event.clipboardData?.types;
+ const TEXT_HTML = 'text/html';
+
+ // If paste contains HTML
+ if (types && types.includes(TEXT_HTML)) {
+ const pastedHTML = event.clipboardData.getData(TEXT_HTML);
+
+ const domparser = new DOMParser();
+ const embeddedImages = domparser.parseFromString(pastedHTML, TEXT_HTML).images;
+
+ // Exclude parsing img tags in the HTML, as fetching the image via fetch triggers a connect-src Content-Security-Policy error.
+ if (embeddedImages.length > 0 && embeddedImages[0].src) {
+ // If HTML has emoji, then treat this as plain text.
+ if (embeddedImages[0].dataset && embeddedImages[0].dataset.stringifyType === 'emoji') {
+ handlePastePlainText(event);
+ return;
+ }
+ }
+ handlePastedHTML(pastedHTML);
+ return;
+ }
+ handlePastePlainText(event);
+ },
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [handlePastedHTML, handlePastePlainText, preHtmlPasteCallback],
+ );
+
+ useEffect(() => {
+ // we need to re-register listener on navigation focus/blur if the component (like Composer) is not unmounting
+ // when navigating away to different screen (report) to avoid paste event on other screen being wrongly handled
+ // by current screen paste listener
+ let unsubscribeFocus: () => void;
+ let unsubscribeBlur: () => void;
+ if (removeListenerOnScreenBlur) {
+ unsubscribeFocus = navigation.addListener('focus', () => document.addEventListener('paste', handlePaste));
+ unsubscribeBlur = navigation.addListener('blur', () => document.removeEventListener('paste', handlePaste));
+ }
+
+ document.addEventListener('paste', handlePaste);
+
+ return () => {
+ if (removeListenerOnScreenBlur) {
+ unsubscribeFocus();
+ unsubscribeBlur();
+ }
+ document.removeEventListener('paste', handlePaste);
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+};
+
+export default useHtmlPaste;
diff --git a/src/hooks/useHtmlPaste/types.ts b/src/hooks/useHtmlPaste/types.ts
new file mode 100644
index 000000000000..305ebe5fbd0f
--- /dev/null
+++ b/src/hooks/useHtmlPaste/types.ts
@@ -0,0 +1,10 @@
+import type {MutableRefObject} from 'react';
+import type {TextInput} from 'react-native';
+
+type UseHtmlPaste = (
+ textInputRef: MutableRefObject<(HTMLTextAreaElement & TextInput) | TextInput | null>,
+ preHtmlPasteCallback?: (event: ClipboardEvent) => boolean,
+ removeListenerOnScreenBlur?: boolean,
+) => void;
+
+export default UseHtmlPaste;
diff --git a/src/hooks/useMarkdownStyle.ts b/src/hooks/useMarkdownStyle.ts
new file mode 100644
index 000000000000..382cce2c8b09
--- /dev/null
+++ b/src/hooks/useMarkdownStyle.ts
@@ -0,0 +1,52 @@
+import type {MarkdownStyle} from '@expensify/react-native-live-markdown';
+import {useMemo} from 'react';
+import FontUtils from '@styles/utils/FontUtils';
+import variables from '@styles/variables';
+import useTheme from './useTheme';
+
+function useMarkdownStyle(): MarkdownStyle {
+ const theme = useTheme();
+
+ const markdownStyle = useMemo(
+ () => ({
+ syntax: {
+ color: theme.syntax,
+ },
+ link: {
+ color: theme.link,
+ },
+ h1: {
+ fontSize: variables.fontSizeh1,
+ },
+ blockquote: {
+ borderColor: theme.border,
+ borderWidth: 4,
+ marginLeft: 0,
+ paddingLeft: 6,
+ },
+ code: {
+ fontFamily: FontUtils.fontFamily.platform.MONOSPACE,
+ color: theme.text,
+ backgroundColor: 'transparent',
+ },
+ pre: {
+ fontFamily: FontUtils.fontFamily.platform.MONOSPACE,
+ color: theme.text,
+ backgroundColor: 'transparent',
+ },
+ mentionHere: {
+ color: theme.ourMentionText,
+ backgroundColor: theme.ourMentionBG,
+ },
+ mentionUser: {
+ color: theme.mentionText,
+ backgroundColor: theme.mentionBG,
+ },
+ }),
+ [theme],
+ );
+
+ return markdownStyle;
+}
+
+export default useMarkdownStyle;
diff --git a/src/hooks/useSearchTermAndSearch.ts b/src/hooks/useSearchTermAndSearch.ts
new file mode 100644
index 000000000000..827b6c6d8bd1
--- /dev/null
+++ b/src/hooks/useSearchTermAndSearch.ts
@@ -0,0 +1,22 @@
+import type {Dispatch} from 'react';
+import {useCallback} from 'react';
+import * as Report from '@userActions/Report';
+
+/**
+ * Hook for fetching reports when user updated search term and hasn't selected max number of participants
+ */
+const useSearchTermAndSearch = (setSearchTerm: Dispatch>, maxParticipantsReached: boolean) => {
+ const setSearchTermAndSearchInServer = useCallback(
+ (text = '') => {
+ if (text && !maxParticipantsReached) {
+ Report.searchInServer(text);
+ }
+ setSearchTerm(text);
+ },
+ [maxParticipantsReached, setSearchTerm],
+ );
+
+ return setSearchTermAndSearchInServer;
+};
+
+export default useSearchTermAndSearch;
diff --git a/src/languages/en.ts b/src/languages/en.ts
index ab687e284b10..145d8414c1e3 100755
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -140,6 +140,7 @@ export default {
magicCode: 'Magic code',
twoFactorCode: 'Two-factor code',
workspaces: 'Workspaces',
+ chats: 'Chats',
profile: 'Profile',
referral: 'Referral',
payments: 'Payments',
@@ -536,6 +537,10 @@ export default {
listOfChats: 'List of chats',
saveTheWorld: 'Save the world',
},
+ allSettingsScreen: {
+ subscriptions: 'Subscriptions',
+ cardsAndDomains: 'Cards & Domains',
+ },
tabSelector: {
chat: 'Chat',
room: 'Room',
@@ -813,6 +818,9 @@ export default {
phrase4: 'Privacy',
},
help: 'Help',
+ accountSettings: 'Account Settings',
+ account: 'Account',
+ general: 'General',
},
closeAccountPage: {
closeAccount: 'Close account',
@@ -1520,6 +1528,7 @@ export default {
travel: 'Travel',
members: 'Members',
plan: 'Plan',
+ overview: 'Overview',
bankAccount: 'Bank account',
connectBankAccount: 'Connect bank account',
testTransactions: 'Test transactions',
@@ -1531,6 +1540,9 @@ export default {
memberNotFound: 'Member not found. To invite a new member to the workspace, please use the Invite button above.',
notAuthorized: `You do not have access to this page. Are you trying to join the workspace? Please reach out to the owner of this workspace so they can add you as a member! Something else? Reach out to ${CONST.EMAIL.CONCIERGE}`,
goToRoom: ({roomName}: GoToRoomParams) => `Go to ${roomName} room`,
+ workspaceName: 'Workspace name',
+ workspaceOwner: 'Owner',
+ workspaceType: 'Workspace type',
workspaceAvatar: 'Workspace avatar',
mustBeOnlineToViewMembers: 'You must be online in order to view members of this workspace.',
},
@@ -1541,7 +1553,7 @@ export default {
},
emptyWorkspace: {
title: 'Create a workspace',
- subtitle: 'Manage business expenses, issue cards, send invoices, and more.',
+ subtitle: 'Workspaces are where you’ll chat with your team, reimburse expenses, issue cards, send invoices, pay bills, and more - all in one place.',
createAWorkspaceCTA: 'Get Started',
features: {
trackAndCollect: 'Track and collect receipts',
@@ -1551,6 +1563,11 @@ export default {
notFound: 'No workspace found',
description: 'Rooms are a great place to discuss and work with multiple people. To begin collaborating, create or join a workspace',
},
+ switcher: {
+ headerTitle: 'Choose a workspace',
+ everythingSection: 'Everything',
+ placeholder: 'Find a workspace',
+ },
new: {
newWorkspace: 'New workspace',
getTheExpensifyCardAndMore: 'Get the Expensify Card and more',
diff --git a/src/languages/es.ts b/src/languages/es.ts
index 167f740c4829..517b3ba1e2f9 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -129,6 +129,7 @@ export default {
magicCode: 'Código mágico',
twoFactorCode: 'Autenticación de dos factores',
workspaces: 'Espacios de trabajo',
+ chats: 'Chats',
profile: 'Perfil',
referral: 'Remisión',
payments: 'Pagos',
@@ -528,6 +529,10 @@ export default {
listOfChats: 'lista de chats',
saveTheWorld: 'Salvar el mundo',
},
+ allSettingsScreen: {
+ subscriptions: 'Suscripciones',
+ cardsAndDomains: 'Tarjetas y Dominios',
+ },
tabSelector: {
chat: 'Chat',
room: 'Sala',
@@ -807,6 +812,9 @@ export default {
phrase4: 'Privacidad',
},
help: 'Ayuda',
+ accountSettings: 'Configuración de la cuenta',
+ account: 'Cuenta',
+ general: 'General',
},
closeAccountPage: {
closeAccount: 'Cerrar cuenta',
@@ -1542,6 +1550,7 @@ export default {
travel: 'Viajes',
members: 'Miembros',
plan: 'Plan',
+ overview: 'Descripción',
bankAccount: 'Cuenta bancaria',
connectBankAccount: 'Conectar cuenta bancaria',
testTransactions: 'Transacciones de prueba',
@@ -1553,6 +1562,9 @@ export default {
memberNotFound: 'Miembro no encontrado. Para invitar a un nuevo miembro al espacio de trabajo, por favor, utiliza el botón Invitar que está arriba.',
notAuthorized: `No tienes acceso a esta página. ¿Estás tratando de unirte al espacio de trabajo? Comunícate con el propietario de este espacio de trabajo para que pueda añadirte como miembro. ¿Necesitas algo más? Comunícate con ${CONST.EMAIL.CONCIERGE}`,
goToRoom: ({roomName}: GoToRoomParams) => `Ir a la sala ${roomName}`,
+ workspaceName: 'Nombre del espacio de trabajo',
+ workspaceOwner: 'Dueño',
+ workspaceType: 'Tipo de espacio de trabajo',
workspaceAvatar: 'Espacio de trabajo avatar',
mustBeOnlineToViewMembers: 'Debes estar en línea para poder ver los miembros de este espacio de trabajo.',
},
@@ -1563,7 +1575,7 @@ export default {
},
emptyWorkspace: {
title: 'Crea un espacio de trabajo',
- subtitle: 'Administra gastos de empresa, emite tarjetas, envía facturas y mucho más.',
+ subtitle: 'En los espacios de trabajo podrás chatear con tu equipo, reembolsar gastos, emitir tarjetas, enviar y pagar facturas, y mucho más - todo en un mismo lugar.',
createAWorkspaceCTA: 'Comenzar',
features: {
trackAndCollect: 'Organiza recibos',
@@ -1573,6 +1585,11 @@ export default {
notFound: 'No se encontró ningún espacio de trabajo',
description: 'Las salas son un gran lugar para discutir y trabajar con varias personas. Para comenzar a colaborar, cree o únase a un espacio de trabajo',
},
+ switcher: {
+ headerTitle: 'Elige un espacio de trabajo',
+ everythingSection: 'Todo',
+ placeholder: 'Encuentra un espacio de trabajo',
+ },
new: {
newWorkspace: 'Nuevo espacio de trabajo',
getTheExpensifyCardAndMore: 'Consigue la Tarjeta Expensify y más',
diff --git a/src/languages/types.ts b/src/languages/types.ts
index 11adf01ac252..d4ec48eb3b41 100644
--- a/src/languages/types.ts
+++ b/src/languages/types.ts
@@ -113,7 +113,7 @@ type SplitAmountParams = {amount: number};
type DidSplitAmountMessageParams = {formattedAmount: string; comment: string};
-type AmountEachParams = {amount: number};
+type AmountEachParams = {amount: string};
type PayerOwesAmountParams = {payer: string; amount: number | string};
diff --git a/src/libs/API/parameters/DismissReferralBannerParams.ts b/src/libs/API/parameters/DismissReferralBannerParams.ts
new file mode 100644
index 000000000000..dbda2d894244
--- /dev/null
+++ b/src/libs/API/parameters/DismissReferralBannerParams.ts
@@ -0,0 +1,10 @@
+import type {ValueOf} from 'type-fest';
+import type CONST from '@src/CONST';
+
+type ContentTypes = ValueOf;
+
+type DismissReferralBannerParams = {
+ type: ContentTypes;
+};
+
+export default DismissReferralBannerParams;
diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts
index 8c0c2fde17cf..b7c3dff7c342 100644
--- a/src/libs/API/parameters/index.ts
+++ b/src/libs/API/parameters/index.ts
@@ -14,6 +14,7 @@ export type {default as ConnectBankAccountWithPlaidParams} from './ConnectBankAc
export type {default as DeleteContactMethodParams} from './DeleteContactMethodParams';
export type {default as DeletePaymentBankAccountParams} from './DeletePaymentBankAccountParams';
export type {default as DeletePaymentCardParams} from './DeletePaymentCardParams';
+export type {default as DismissReferralBannerParams} from './DismissReferralBannerParams';
export type {default as ExpandURLPreviewParams} from './ExpandURLPreviewParams';
export type {default as GetMissingOnyxMessagesParams} from './GetMissingOnyxMessagesParams';
export type {default as GetNewerActionsParams} from './GetNewerActionsParams';
diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts
index 05b658ee0702..c011fa395f0f 100644
--- a/src/libs/API/types.ts
+++ b/src/libs/API/types.ts
@@ -8,6 +8,7 @@ import type UpdateBeneficialOwnersForBankAccountParams from './parameters/Update
type ApiRequest = ValueOf;
const WRITE_COMMANDS = {
+ DISMISS_REFERRAL_BANNER: 'DismissReferralBanner',
UPDATE_PREFERRED_LOCALE: 'UpdatePreferredLocale',
RECONNECT_APP: 'ReconnectApp',
OPEN_PROFILE: 'OpenProfile',
@@ -120,6 +121,7 @@ const WRITE_COMMANDS = {
type WriteCommand = ValueOf;
type WriteCommandParameters = {
+ [WRITE_COMMANDS.DISMISS_REFERRAL_BANNER]: Parameters.DismissReferralBannerParams;
[WRITE_COMMANDS.UPDATE_PREFERRED_LOCALE]: Parameters.UpdatePreferredLocaleParams;
[WRITE_COMMANDS.RECONNECT_APP]: Parameters.ReconnectAppParams;
[WRITE_COMMANDS.OPEN_PROFILE]: Parameters.OpenProfileParams;
diff --git a/src/libs/BrickRoadsUtils.ts b/src/libs/BrickRoadsUtils.ts
deleted file mode 100644
index db7cc40a7940..000000000000
--- a/src/libs/BrickRoadsUtils.ts
+++ /dev/null
@@ -1,74 +0,0 @@
-import type {OnyxCollection} from 'react-native-onyx';
-import Onyx from 'react-native-onyx';
-import type {ValueOf} from 'type-fest';
-import CONST from '@src/CONST';
-import ONYXKEYS from '@src/ONYXKEYS';
-import type {Report} from '@src/types/onyx';
-import * as OptionsListUtils from './OptionsListUtils';
-import * as ReportActionsUtils from './ReportActionsUtils';
-import * as ReportUtils from './ReportUtils';
-
-let allReports: OnyxCollection;
-
-type BrickRoad = ValueOf | undefined;
-
-Onyx.connect({
- key: ONYXKEYS.COLLECTION.REPORT,
- waitForCollectionCallback: true,
- callback: (value) => (allReports = value),
-});
-
-/**
- * @param report
- * @returns BrickRoad for the policy passed as a param
- */
-const getBrickRoadForPolicy = (report: Report): BrickRoad => {
- const reportActions = ReportActionsUtils.getAllReportActions(report.reportID);
- const reportErrors = OptionsListUtils.getAllReportErrors(report, reportActions);
- const doesReportContainErrors = Object.keys(reportErrors ?? {}).length !== 0 ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined;
- if (doesReportContainErrors) {
- return CONST.BRICK_ROAD.RBR;
- }
-
- // To determine if the report requires attention from the current user, we need to load the parent report action
- let itemParentReportAction = {};
- if (report.parentReportID) {
- const itemParentReportActions = ReportActionsUtils.getAllReportActions(report.parentReportID);
- itemParentReportAction = report.parentReportActionID ? itemParentReportActions[report.parentReportActionID] : {};
- }
- const reportOption = {...report, isUnread: ReportUtils.isUnread(report), isUnreadWithMention: ReportUtils.isUnreadWithMention(report)};
- const shouldShowGreenDotIndicator = ReportUtils.requiresAttentionFromCurrentUser(reportOption, itemParentReportAction);
- return shouldShowGreenDotIndicator ? CONST.BRICK_ROAD.GBR : undefined;
-};
-
-/**
- * @returns a map where the keys are policyIDs and the values are BrickRoads for each policy
- */
-function getWorkspacesBrickRoads(): Record {
- if (!allReports) {
- return {};
- }
-
- // The key in this map is the workspace id
- const workspacesBrickRoadsMap: Record = {};
-
- Object.keys(allReports).forEach((report) => {
- const policyID = allReports?.[report]?.policyID;
- const policyReport = allReports ? allReports[report] : null;
- if (!policyID || !policyReport || workspacesBrickRoadsMap[policyID] === CONST.BRICK_ROAD.RBR) {
- return;
- }
- const workspaceBrickRoad = getBrickRoadForPolicy(policyReport);
-
- if (!workspaceBrickRoad && !!workspacesBrickRoadsMap[policyID]) {
- return;
- }
-
- workspacesBrickRoadsMap[policyID] = workspaceBrickRoad;
- });
-
- return workspacesBrickRoadsMap;
-}
-
-export {getBrickRoadForPolicy, getWorkspacesBrickRoads};
-export type {BrickRoad};
diff --git a/src/libs/EmojiUtils.ts b/src/libs/EmojiUtils.ts
index e34fa0b90fc6..fe79ea68a0b3 100644
--- a/src/libs/EmojiUtils.ts
+++ b/src/libs/EmojiUtils.ts
@@ -548,6 +548,18 @@ const getEmojiReactionDetails = (emojiName: string, reaction: ReportActionReacti
};
};
+function getSpacersIndexes(allEmojis: EmojiPickerList): number[] {
+ const spacersIndexes: number[] = [];
+ allEmojis.forEach((emoji, index) => {
+ if (!(CONST.EMOJI_PICKER_ITEM_TYPES.SPACER in emoji)) {
+ return;
+ }
+
+ spacersIndexes.push(index);
+ });
+ return spacersIndexes;
+}
+
export {
findEmojiByName,
findEmojiByCode,
@@ -570,4 +582,5 @@ export {
getAddedEmojis,
isFirstLetterEmoji,
hasAccountIDEmojiReacted,
+ getSpacersIndexes,
};
diff --git a/src/libs/ErrorUtils.ts b/src/libs/ErrorUtils.ts
index 2466a262b4b9..edc24bf94720 100644
--- a/src/libs/ErrorUtils.ts
+++ b/src/libs/ErrorUtils.ts
@@ -66,6 +66,18 @@ function getLatestErrorMessage(onyxData: T
return errors[key];
}
+function getLatestErrorMessageField(onyxData: TOnyxData): Record {
+ const errors = onyxData.errors ?? {};
+
+ if (Object.keys(errors).length === 0) {
+ return {};
+ }
+
+ const key = Object.keys(errors).sort().reverse()[0];
+
+ return {key: errors[key]};
+}
+
type OnyxDataWithErrorFields = {
errorFields?: ErrorFields;
};
@@ -119,4 +131,13 @@ function addErrorMessage(errors: ErrorsList, inpu
}
}
-export {getAuthenticateErrorMessage, getMicroSecondOnyxError, getMicroSecondOnyxErrorObject, getLatestErrorMessage, getLatestErrorField, getEarliestErrorField, addErrorMessage};
+export {
+ getAuthenticateErrorMessage,
+ getMicroSecondOnyxError,
+ getMicroSecondOnyxErrorObject,
+ getLatestErrorMessage,
+ getLatestErrorField,
+ getEarliestErrorField,
+ addErrorMessage,
+ getLatestErrorMessageField,
+};
diff --git a/src/libs/LocalePhoneNumber.ts b/src/libs/LocalePhoneNumber.ts
index 933aa7937560..9aacc6968e1e 100644
--- a/src/libs/LocalePhoneNumber.ts
+++ b/src/libs/LocalePhoneNumber.ts
@@ -13,7 +13,7 @@ Onyx.connect({
* Returns a locally converted phone number for numbers from the same region
* and an internationally converted phone number with the country code for numbers from other regions
*/
-function formatPhoneNumber(number: string): string {
+function formatPhoneNumber(number: string | undefined): string {
if (!number) {
return '';
}
diff --git a/src/libs/Localize/index.ts b/src/libs/Localize/index.ts
index bc40f93dd13b..46ca550eaa1a 100644
--- a/src/libs/Localize/index.ts
+++ b/src/libs/Localize/index.ts
@@ -7,6 +7,7 @@ import CONST from '@src/CONST';
import translations from '@src/languages/translations';
import type {TranslationFlatObject, TranslationPaths} from '@src/languages/types';
import ONYXKEYS from '@src/ONYXKEYS';
+import type {ReceiptError} from '@src/types/onyx/Transaction';
import LocaleListener from './LocaleListener';
import BaseLocaleListener from './LocaleListener/BaseLocaleListener';
@@ -102,7 +103,10 @@ type MaybePhraseKey = string | [string, Record & {isTranslated?
/**
* Return translated string for given error.
*/
-function translateIfPhraseKey(message: MaybePhraseKey): string {
+function translateIfPhraseKey(message: MaybePhraseKey): string;
+function translateIfPhraseKey(message: ReceiptError): ReceiptError;
+function translateIfPhraseKey(message: MaybePhraseKey | ReceiptError): string | ReceiptError;
+function translateIfPhraseKey(message: MaybePhraseKey | ReceiptError): string | ReceiptError {
if (!message || (Array.isArray(message) && message.length === 0)) {
return '';
}
diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx
index e9fcb57df1da..00c96d436496 100644
--- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx
+++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx
@@ -8,11 +8,11 @@ import useWindowDimensions from '@hooks/useWindowDimensions';
import KeyboardShortcut from '@libs/KeyboardShortcut';
import getCurrentUrl from '@libs/Navigation/currentUrl';
import Navigation from '@libs/Navigation/Navigation';
+import type {AuthScreensParamList} from '@libs/Navigation/types';
import NetworkConnection from '@libs/NetworkConnection';
import * as Pusher from '@libs/Pusher/pusher';
import PusherConnectionManager from '@libs/PusherConnectionManager';
import * as SessionUtils from '@libs/SessionUtils';
-import type {AuthScreensParamList} from '@navigation/types';
import NotFoundPage from '@pages/ErrorPage/NotFoundPage';
import DesktopSignInRedirectPage from '@pages/signin/DesktopSignInRedirectPage';
import SearchInputManager from '@pages/workspace/SearchInputManager';
@@ -36,7 +36,9 @@ import type {SelectedTimezone, Timezone} from '@src/types/onyx/PersonalDetails';
import createCustomStackNavigator from './createCustomStackNavigator';
import defaultScreenOptions from './defaultScreenOptions';
import getRootNavigatorScreenOptions from './getRootNavigatorScreenOptions';
+import BottomTabNavigator from './Navigators/BottomTabNavigator';
import CentralPaneNavigator from './Navigators/CentralPaneNavigator';
+import FullScreenNavigator from './Navigators/FullScreenNavigator';
import LeftModalNavigator from './Navigators/LeftModalNavigator';
import RightModalNavigator from './Navigators/RightModalNavigator';
@@ -55,7 +57,6 @@ type AuthScreensProps = {
};
const loadReportAttachments = () => require('../../../pages/home/report/ReportAttachments').default as React.ComponentType;
-const loadSidebarScreen = () => require('../../../pages/home/sidebar/SidebarScreen').default as React.ComponentType;
const loadValidateLoginPage = () => require('../../../pages/ValidateLoginPage').default as React.ComponentType;
const loadLogOutPreviousUserPage = () => require('../../../pages/LogOutPreviousUserPage').default as React.ComponentType;
const loadConciergePage = () => require('../../../pages/ConciergePage').default as React.ComponentType;
@@ -264,9 +265,9 @@ function AuthScreens({session, lastOpenedPublicRoomID, isUsingMemoryOnlyKeys = f
+
React.ComponentType>>;
@@ -39,8 +41,9 @@ type Screens = Partial React.ComponentType>>;
* Create a modal stack navigator with an array of sub-screens.
*
* @param screens key/value pairs where the key is the name of the screen and the value is a functon that returns the lazy-loaded component
+ * @param getScreenOptions optional function that returns the screen options, override the default options
*/
-function createModalStackNavigator(screens: Screens): React.ComponentType {
+function createModalStackNavigator(screens: Screens, getScreenOptions?: (styles: ThemeStyles) => StackNavigationOptions): React.ComponentType {
const ModalStackNavigator = createStackNavigator();
function ModalStack() {
@@ -56,7 +59,7 @@ function createModalStackNavigator(screens:
);
return (
-
+
{Object.keys(screens as Required).map((name) => (
require('../../../pages/TeachersUnite/ImTeacherPage').default as React.ComponentType,
});
+const AccountSettingsModalStackNavigator = createModalStackNavigator(
+ {
+ [SCREENS.SETTINGS.PREFERENCES.ROOT]: () => require('../../../pages/settings/Preferences/PreferencesPage').default as React.ComponentType,
+ [SCREENS.SETTINGS.SECURITY]: () => require('../../../pages/settings/Security/SecuritySettingsPage').default as React.ComponentType,
+ [SCREENS.SETTINGS.PROFILE.ROOT]: () => require('../../../pages/settings/Profile/ProfilePage').default as React.ComponentType,
+ [SCREENS.SETTINGS.SHARE_CODE]: () => require('../../../pages/ShareCodePage').default as React.ComponentType,
+ [SCREENS.SETTINGS.WALLET.ROOT]: () => require('../../../pages/settings/Wallet/WalletPage').default as React.ComponentType,
+ [SCREENS.SETTINGS.ABOUT]: () => require('../../../pages/settings/AboutPage/AboutPage').default as React.ComponentType,
+ },
+ (styles) => ({cardStyle: styles.navigationScreenCardStyle, headerShown: false}),
+);
+
+const WorkspaceSwitcherModalStackNavigator = createModalStackNavigator({
+ [SCREENS.WORKSPACE_SWITCHER.ROOT]: () => require('../../../pages/WorkspaceSwitcherPage').default as React.ComponentType,
+});
+
const SettingsModalStackNavigator = createModalStackNavigator({
- [SCREENS.SETTINGS.ROOT]: () => require('../../../pages/settings/InitialSettingsPage').default as React.ComponentType,
- [SCREENS.SETTINGS.SHARE_CODE]: () => require('../../../pages/ShareCodePage').default as React.ComponentType,
- [SCREENS.SETTINGS.WORKSPACES]: () => require('../../../pages/workspace/WorkspacesListPage').default as React.ComponentType,
- [SCREENS.SETTINGS.PROFILE.ROOT]: () => require('../../../pages/settings/Profile/ProfilePage').default as React.ComponentType,
[SCREENS.SETTINGS.PROFILE.PRONOUNS]: () => require('../../../pages/settings/Profile/PronounsPage').default as React.ComponentType,
[SCREENS.SETTINGS.PROFILE.DISPLAY_NAME]: () => require('../../../pages/settings/Profile/DisplayNamePage').default as React.ComponentType,
[SCREENS.SETTINGS.PROFILE.TIMEZONE]: () => require('../../../pages/settings/Profile/TimezoneInitialPage').default as React.ComponentType,
@@ -197,16 +212,12 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../pages/settings/Profile/Contacts/ContactMethodsPage').default as React.ComponentType,
[SCREENS.SETTINGS.PROFILE.CONTACT_METHOD_DETAILS]: () => require('../../../pages/settings/Profile/Contacts/ContactMethodDetailsPage').default as React.ComponentType,
[SCREENS.SETTINGS.PROFILE.NEW_CONTACT_METHOD]: () => require('../../../pages/settings/Profile/Contacts/NewContactMethodPage').default as React.ComponentType,
- [SCREENS.SETTINGS.PREFERENCES.ROOT]: () => require('../../../pages/settings/Preferences/PreferencesPage').default as React.ComponentType,
[SCREENS.SETTINGS.PREFERENCES.PRIORITY_MODE]: () => require('../../../pages/settings/Preferences/PriorityModePage').default as React.ComponentType,
[SCREENS.SETTINGS.PREFERENCES.LANGUAGE]: () => require('../../../pages/settings/Preferences/LanguagePage').default as React.ComponentType,
[SCREENS.SETTINGS.PREFERENCES.THEME]: () => require('../../../pages/settings/Preferences/ThemePage').default as React.ComponentType,
[SCREENS.SETTINGS.CLOSE]: () => require('../../../pages/settings/Security/CloseAccountPage').default as React.ComponentType,
- [SCREENS.SETTINGS.SECURITY]: () => require('../../../pages/settings/Security/SecuritySettingsPage').default as React.ComponentType,
- [SCREENS.SETTINGS.ABOUT]: () => require('../../../pages/settings/AboutPage/AboutPage').default as React.ComponentType,
[SCREENS.SETTINGS.APP_DOWNLOAD_LINKS]: () => require('../../../pages/settings/AppDownloadLinks').default as React.ComponentType,
[SCREENS.SETTINGS.LOUNGE_ACCESS]: () => require('../../../pages/settings/Profile/LoungeAccessPage').default as React.ComponentType,
- [SCREENS.SETTINGS.WALLET.ROOT]: () => require('../../../pages/settings/Wallet/WalletPage').default as React.ComponentType,
[SCREENS.SETTINGS.WALLET.CARDS_DIGITAL_DETAILS_UPDATE_ADDRESS]: () => require('../../../pages/settings/Profile/PersonalDetails/AddressPage').default as React.ComponentType,
[SCREENS.SETTINGS.WALLET.DOMAIN_CARD]: () => require('../../../pages/settings/Wallet/ExpensifyCardPage').default as React.ComponentType,
[SCREENS.SETTINGS.WALLET.REPORT_VIRTUAL_CARD_FRAUD]: () => require('../../../pages/settings/Wallet/ReportVirtualCardFraudPage').default as React.ComponentType,
@@ -224,18 +235,11 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../pages/settings/Profile/CustomStatus/StatusClearAfterPage').default as React.ComponentType,
[SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_DATE]: () => require('../../../pages/settings/Profile/CustomStatus/SetDatePage').default as React.ComponentType,
[SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_TIME]: () => require('../../../pages/settings/Profile/CustomStatus/SetTimePage').default as React.ComponentType,
- [SCREENS.WORKSPACE.INITIAL]: () => require('../../../pages/workspace/WorkspaceInitialPage').default as React.ComponentType,
- [SCREENS.WORKSPACE.SETTINGS]: () => require('../../../pages/workspace/WorkspaceSettingsPage').default as React.ComponentType,
- [SCREENS.WORKSPACE.CURRENCY]: () => require('../../../pages/workspace/WorkspaceSettingsCurrencyPage').default as React.ComponentType,
- [SCREENS.WORKSPACE.CARD]: () => require('../../../pages/workspace/card/WorkspaceCardPage').default as React.ComponentType,
- [SCREENS.WORKSPACE.REIMBURSE]: () => require('../../../pages/workspace/reimburse/WorkspaceReimbursePage').default as React.ComponentType,
[SCREENS.WORKSPACE.RATE_AND_UNIT]: () => require('../../../pages/workspace/reimburse/WorkspaceRateAndUnitPage').default as React.ComponentType,
- [SCREENS.WORKSPACE.BILLS]: () => require('../../../pages/workspace/bills/WorkspaceBillsPage').default as React.ComponentType,
- [SCREENS.WORKSPACE.INVOICES]: () => require('../../../pages/workspace/invoices/WorkspaceInvoicesPage').default as React.ComponentType,
- [SCREENS.WORKSPACE.TRAVEL]: () => require('../../../pages/workspace/travel/WorkspaceTravelPage').default as React.ComponentType,
- [SCREENS.WORKSPACE.MEMBERS]: () => require('../../../pages/workspace/WorkspaceMembersPage').default as React.ComponentType,
[SCREENS.WORKSPACE.INVITE]: () => require('../../../pages/workspace/WorkspaceInvitePage').default as React.ComponentType,
[SCREENS.WORKSPACE.INVITE_MESSAGE]: () => require('../../../pages/workspace/WorkspaceInviteMessagePage').default as React.ComponentType,
+ [SCREENS.WORKSPACE.NAME]: () => require('../../../pages/workspace/WorkspaceNamePage').default as React.ComponentType,
+ [SCREENS.WORKSPACE.CURRENCY]: () => require('../../../pages/workspace/WorkspaceOverviewCurrencyPage').default as React.ComponentType,
[SCREENS.REIMBURSEMENT_ACCOUNT]: () => require('../../../pages/ReimbursementAccount/ReimbursementAccountPage').default as React.ComponentType,
[SCREENS.GET_ASSISTANCE]: () => require('../../../pages/GetAssistancePage').default as React.ComponentType,
[SCREENS.SETTINGS.TWO_FACTOR_AUTH]: () => require('../../../pages/settings/Security/TwoFactorAuth/TwoFactorAuthPage').default as React.ComponentType,
@@ -287,30 +291,32 @@ const ProcessMoneyRequestHoldStackNavigator = createModalStackNavigator({
});
export {
- MoneyRequestModalStackNavigator,
- SplitDetailsModalStackNavigator,
+ AccountSettingsModalStackNavigator,
+ AddPersonalBankAccountModalStackNavigator,
DetailsModalStackNavigator,
+ EditRequestStackNavigator,
+ EnablePaymentsStackNavigator,
+ FlagCommentStackNavigator,
+ MoneyRequestModalStackNavigator,
+ NewChatModalStackNavigator,
+ NewTaskModalStackNavigator,
+ NewTeachersUniteNavigator,
+ PrivateNotesModalStackNavigator,
ProfileModalStackNavigator,
+ ReferralModalStackNavigator,
+ WorkspaceSwitcherModalStackNavigator,
+ ReimbursementAccountModalStackNavigator,
ReportDetailsModalStackNavigator,
- TaskModalStackNavigator,
+ ReportParticipantsModalStackNavigator,
ReportSettingsModalStackNavigator,
ReportWelcomeMessageModalStackNavigator,
- ReportParticipantsModalStackNavigator,
+ RoomInviteModalStackNavigator,
+ RoomMembersModalStackNavigator,
SearchModalStackNavigator,
- NewChatModalStackNavigator,
- NewTaskModalStackNavigator,
SettingsModalStackNavigator,
- EnablePaymentsStackNavigator,
- AddPersonalBankAccountModalStackNavigator,
- ReimbursementAccountModalStackNavigator,
- WalletStatementStackNavigator,
- FlagCommentStackNavigator,
- EditRequestStackNavigator,
- PrivateNotesModalStackNavigator,
- NewTeachersUniteNavigator,
SignInModalStackNavigator,
- RoomMembersModalStackNavigator,
- RoomInviteModalStackNavigator,
- ReferralModalStackNavigator,
+ SplitDetailsModalStackNavigator,
+ TaskModalStackNavigator,
+ WalletStatementStackNavigator,
ProcessMoneyRequestHoldStackNavigator,
};
diff --git a/src/libs/Navigation/AppNavigator/Navigators/ActiveRouteContext.ts b/src/libs/Navigation/AppNavigator/Navigators/ActiveRouteContext.ts
new file mode 100644
index 000000000000..d1d14d43af1a
--- /dev/null
+++ b/src/libs/Navigation/AppNavigator/Navigators/ActiveRouteContext.ts
@@ -0,0 +1,5 @@
+import React from 'react';
+
+const ActiveRouteContext = React.createContext('');
+
+export default ActiveRouteContext;
diff --git a/src/libs/Navigation/AppNavigator/Navigators/BottomTabNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/BottomTabNavigator.tsx
new file mode 100644
index 000000000000..ce03a8d5bcba
--- /dev/null
+++ b/src/libs/Navigation/AppNavigator/Navigators/BottomTabNavigator.tsx
@@ -0,0 +1,45 @@
+import {useNavigationState} from '@react-navigation/native';
+import type {StackNavigationOptions} from '@react-navigation/stack';
+import React from 'react';
+import createCustomBottomTabNavigator from '@libs/Navigation/AppNavigator/createCustomBottomTabNavigator';
+import getTopmostCentralPaneRoute from '@libs/Navigation/getTopmostCentralPaneRoute';
+import type {BottomTabNavigatorParamList} from '@libs/Navigation/types';
+import AllSettingsScreen from '@pages/home/sidebar/AllSettingsScreen';
+import SidebarScreen from '@pages/home/sidebar/SidebarScreen';
+import SCREENS from '@src/SCREENS';
+import ActiveRouteContext from './ActiveRouteContext';
+
+const loadWorkspaceInitialPage = () => require('../../../../pages/workspace/WorkspaceInitialPage').default as React.ComponentType;
+
+const Tab = createCustomBottomTabNavigator();
+
+const screenOptions: StackNavigationOptions = {
+ headerShown: false,
+ animationEnabled: false,
+};
+
+function BottomTabNavigator() {
+ const activeRoute = useNavigationState(getTopmostCentralPaneRoute);
+ return (
+
+
+
+
+
+
+
+ );
+}
+
+BottomTabNavigator.displayName = 'BottomTabNavigator';
+
+export default BottomTabNavigator;
diff --git a/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/BaseCentralPaneNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/BaseCentralPaneNavigator.tsx
index f2cdd140f7d8..53928b71be4e 100644
--- a/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/BaseCentralPaneNavigator.tsx
+++ b/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/BaseCentralPaneNavigator.tsx
@@ -11,23 +11,44 @@ const Stack = createStackNavigator();
const url = getCurrentUrl();
const openOnAdminRoom = url ? new URL(url).searchParams.get('openOnAdminRoom') : undefined;
+type Screens = Partial React.ComponentType>>;
+
+const workspaceSettingsScreens = {
+ [SCREENS.SETTINGS.WORKSPACES]: () => require('../../../../../pages/workspace/WorkspacesListPage').default as React.ComponentType,
+ [SCREENS.WORKSPACE.OVERVIEW]: () => require('../../../../../pages/workspace/WorkspaceOverviewPage').default as React.ComponentType,
+ [SCREENS.WORKSPACE.CARD]: () => require('../../../../../pages/workspace/card/WorkspaceCardPage').default as React.ComponentType,
+ [SCREENS.WORKSPACE.REIMBURSE]: () => require('../../../../../pages/workspace/reimburse/WorkspaceReimbursePage').default as React.ComponentType,
+ [SCREENS.WORKSPACE.BILLS]: () => require('../../../../../pages/workspace/bills/WorkspaceBillsPage').default as React.ComponentType,
+ [SCREENS.WORKSPACE.INVOICES]: () => require('../../../../../pages/workspace/invoices/WorkspaceInvoicesPage').default as React.ComponentType,
+ [SCREENS.WORKSPACE.TRAVEL]: () => require('../../../../../pages/workspace/travel/WorkspaceTravelPage').default as React.ComponentType,
+ [SCREENS.WORKSPACE.MEMBERS]: () => require('../../../../../pages/workspace/WorkspaceMembersPage').default as React.ComponentType,
+} satisfies Screens;
+
function BaseCentralPaneNavigator() {
const styles = useThemeStyles();
+ const options = {
+ headerShown: false,
+ title: 'New Expensify',
+
+ // Prevent unnecessary scrolling
+ cardStyle: styles.cardStyleNavigator,
+ };
return (
-
+
+
+ {Object.entries(workspaceSettingsScreens).map(([screenName, componentGetter]) => (
+
+ ))}
);
}
diff --git a/src/libs/Navigation/AppNavigator/Navigators/FullScreenNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/FullScreenNavigator.tsx
new file mode 100644
index 000000000000..e7f40bc963ee
--- /dev/null
+++ b/src/libs/Navigation/AppNavigator/Navigators/FullScreenNavigator.tsx
@@ -0,0 +1,41 @@
+import React from 'react';
+import {View} from 'react-native';
+import useStyleUtils from '@hooks/useStyleUtils';
+import useThemeStyles from '@hooks/useThemeStyles';
+import useWindowDimensions from '@hooks/useWindowDimensions';
+import createCustomFullScreenNavigator from '@libs/Navigation/AppNavigator/createCustomFullScreenNavigator';
+import getRootNavigatorScreenOptions from '@libs/Navigation/AppNavigator/getRootNavigatorScreenOptions';
+import * as ModalStackNavigators from '@libs/Navigation/AppNavigator/ModalStackNavigators';
+import SCREENS from '@src/SCREENS';
+
+const loadInitialSettingsPage = () => require('../../../../pages/settings/InitialSettingsPage').default as React.ComponentType;
+
+const RootStack = createCustomFullScreenNavigator();
+
+function FullScreenNavigator() {
+ const styles = useThemeStyles();
+ const StyleUtils = useStyleUtils();
+ const {isSmallScreenWidth} = useWindowDimensions();
+ const screenOptions = getRootNavigatorScreenOptions(isSmallScreenWidth, styles, StyleUtils);
+
+ return (
+
+
+
+
+
+
+ );
+}
+
+FullScreenNavigator.displayName = 'FullScreenNavigator';
+
+export default FullScreenNavigator;
diff --git a/src/libs/Navigation/AppNavigator/Navigators/LeftModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/LeftModalNavigator.tsx
index 4e78231b6b6e..8f76d8fbdd7b 100644
--- a/src/libs/Navigation/AppNavigator/Navigators/LeftModalNavigator.tsx
+++ b/src/libs/Navigation/AppNavigator/Navigators/LeftModalNavigator.tsx
@@ -35,6 +35,10 @@ function LeftModalNavigator({navigation}: LeftModalNavigatorProps) {
name={SCREENS.LEFT_MODAL.SEARCH}
component={ModalStackNavigators.SearchModalStackNavigator}
/>
+
diff --git a/src/libs/Navigation/AppNavigator/PublicScreens.tsx b/src/libs/Navigation/AppNavigator/PublicScreens.tsx
index 5c3171214bd9..6b1557994627 100644
--- a/src/libs/Navigation/AppNavigator/PublicScreens.tsx
+++ b/src/libs/Navigation/AppNavigator/PublicScreens.tsx
@@ -8,6 +8,7 @@ import SAMLSignInPage from '@pages/signin/SAMLSignInPage';
import SignInPage from '@pages/signin/SignInPage';
import UnlinkLoginPage from '@pages/UnlinkLoginPage';
import ValidateLoginPage from '@pages/ValidateLoginPage';
+import NAVIGATORS from '@src/NAVIGATORS';
import SCREENS from '@src/SCREENS';
import defaultScreenOptions from './defaultScreenOptions';
@@ -16,8 +17,9 @@ const RootStack = createStackNavigator();
function PublicScreens() {
return (
+ {/* The structure for the HOME route has to be the same in public and auth screens. That's why the name for SignInPage is BOTTOM_TAB_NAVIGATOR. */}
diff --git a/src/libs/Navigation/AppNavigator/ReportScreenIDSetter.ts b/src/libs/Navigation/AppNavigator/ReportScreenIDSetter.ts
index 0cc6aa27e313..b4bb56262860 100644
--- a/src/libs/Navigation/AppNavigator/ReportScreenIDSetter.ts
+++ b/src/libs/Navigation/AppNavigator/ReportScreenIDSetter.ts
@@ -1,11 +1,13 @@
import {useEffect} from 'react';
import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
import {withOnyx} from 'react-native-onyx';
+import useActiveWorkspace from '@hooks/useActiveWorkspace';
import usePermissions from '@hooks/usePermissions';
+import {getPolicyMembersByIdWithoutCurrentUser} from '@libs/PolicyUtils';
import * as ReportUtils from '@libs/ReportUtils';
import * as App from '@userActions/App';
import ONYXKEYS from '@src/ONYXKEYS';
-import type {Policy, Report, ReportMetadata} from '@src/types/onyx';
+import type {Policy, PolicyMembers, Report, ReportMetadata} from '@src/types/onyx';
import type {ReportScreenWrapperProps} from './ReportScreenWrapper';
type ReportScreenIDSetterComponentProps = {
@@ -15,11 +17,17 @@ type ReportScreenIDSetterComponentProps = {
/** The policies which the user has access to */
policies: OnyxCollection;
+ /** Members of all the workspaces the user is member of */
+ policyMembers: OnyxCollection;
+
/** Whether user is a new user */
isFirstTimeNewExpensifyUser: OnyxEntry;
/** The report metadata */
reportMetadata: OnyxCollection;
+
+ /** The accountID of the current user */
+ accountID?: number;
};
type ReportScreenIDSetterProps = ReportScreenIDSetterComponentProps & ReportScreenWrapperProps;
@@ -51,8 +59,10 @@ const getLastAccessedReportID = (
};
// This wrapper is reponsible for opening the last accessed report if there is no reportID specified in the route params
-function ReportScreenIDSetter({route, reports, policies, navigation, isFirstTimeNewExpensifyUser = false, reportMetadata}: ReportScreenIDSetterProps) {
+function ReportScreenIDSetter({route, reports, policies, policyMembers = {}, navigation, isFirstTimeNewExpensifyUser = false, reportMetadata, accountID}: ReportScreenIDSetterProps) {
const {canUseDefaultRooms} = usePermissions();
+ const {activeWorkspaceID} = useActiveWorkspace();
+
useEffect(() => {
// Don't update if there is a reportID in the params already
if (route?.params?.reportID) {
@@ -65,8 +75,19 @@ function ReportScreenIDSetter({route, reports, policies, navigation, isFirstTime
return;
}
+ const policyMemberAccountIDs = getPolicyMembersByIdWithoutCurrentUser(policyMembers, activeWorkspaceID, accountID);
+
// If there is no reportID in route, try to find last accessed and use it for setParams
- const reportID = getLastAccessedReportID(reports, !canUseDefaultRooms, policies, isFirstTimeNewExpensifyUser, !!reports?.params?.openOnAdminRoom, reportMetadata);
+ const reportID = getLastAccessedReportID(
+ reports,
+ !canUseDefaultRooms,
+ policies,
+ isFirstTimeNewExpensifyUser,
+ !!reports?.params?.openOnAdminRoom,
+ reportMetadata,
+ activeWorkspaceID,
+ policyMemberAccountIDs,
+ );
// It's possible that reports aren't fully loaded yet
// in that case the reportID is undefined
@@ -75,7 +96,7 @@ function ReportScreenIDSetter({route, reports, policies, navigation, isFirstTime
} else {
App.confirmReadyToOpenApp();
}
- }, [route, navigation, reports, canUseDefaultRooms, policies, isFirstTimeNewExpensifyUser, reportMetadata]);
+ }, [route, navigation, reports, canUseDefaultRooms, policies, isFirstTimeNewExpensifyUser, reportMetadata, activeWorkspaceID, policyMembers, accountID]);
// The ReportScreen without the reportID set will display a skeleton
// until the reportID is loaded and set in the route param
@@ -93,6 +114,10 @@ export default withOnyx session?.accountID,
+ },
})(ReportScreenIDSetter);
diff --git a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx
new file mode 100644
index 000000000000..7f7e86b3dafb
--- /dev/null
+++ b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx
@@ -0,0 +1,96 @@
+import {useNavigationState} from '@react-navigation/native';
+import React from 'react';
+import {View} from 'react-native';
+import Icon from '@components/Icon';
+import * as Expensicons from '@components/Icon/Expensicons';
+import {PressableWithFeedback} from '@components/Pressable';
+import PurposeForUsingExpensifyModal from '@components/PurposeForUsingExpensifyModal';
+import Tooltip from '@components/Tooltip';
+import useActiveWorkspace from '@hooks/useActiveWorkspace';
+import useLocalize from '@hooks/useLocalize';
+import useTheme from '@hooks/useTheme';
+import useThemeStyles from '@hooks/useThemeStyles';
+import interceptAnonymousUser from '@libs/interceptAnonymousUser';
+import getTopmostBottomTabRoute from '@libs/Navigation/getTopmostBottomTabRoute';
+import Navigation from '@libs/Navigation/Navigation';
+import type {RootStackParamList} from '@libs/Navigation/types';
+import {checkIfWorkspaceSettingsTabHasRBR, getChatTabBrickRoad} from '@libs/WorkspacesSettingsUtils';
+import BottomTabBarFloatingActionButton from '@pages/home/sidebar/BottomTabBarFloatingActionButton';
+import variables from '@styles/variables';
+import CONST from '@src/CONST';
+import ROUTES from '@src/ROUTES';
+import SCREENS from '@src/SCREENS';
+
+function BottomTabBar() {
+ const theme = useTheme();
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+ const {activeWorkspaceID} = useActiveWorkspace();
+
+ // Parent navigator of the bottom tab bar is the root navigator.
+ const currentTabName = useNavigationState((state) => {
+ const topmostBottomTabRoute = getTopmostBottomTabRoute(state);
+ return topmostBottomTabRoute?.name ?? SCREENS.HOME;
+ });
+
+ const shouldShowWorkspaceRedBrickRoad = checkIfWorkspaceSettingsTabHasRBR(activeWorkspaceID) && currentTabName === SCREENS.HOME;
+
+ const chatTabBrickRoad = currentTabName !== SCREENS.HOME ? getChatTabBrickRoad(activeWorkspaceID) : undefined;
+
+ return (
+
+
+ {
+ Navigation.navigate(ROUTES.HOME);
+ }}
+ role={CONST.ROLE.BUTTON}
+ accessibilityLabel={translate('common.chats')}
+ wrapperStyle={styles.flexGrow1}
+ style={styles.bottomTabBarItem}
+ >
+
+
+ {chatTabBrickRoad && (
+
+ )}
+
+
+
+
+
+
+ interceptAnonymousUser(() =>
+ activeWorkspaceID ? Navigation.navigate(ROUTES.WORKSPACE_INITIAL.getRoute(activeWorkspaceID)) : Navigation.navigate(ROUTES.ALL_SETTINGS),
+ )
+ }
+ role={CONST.ROLE.BUTTON}
+ accessibilityLabel={translate('common.settings')}
+ wrapperStyle={styles.flexGrow1}
+ style={styles.bottomTabBarItem}
+ >
+
+
+ {shouldShowWorkspaceRedBrickRoad && }
+
+
+
+
+
+ );
+}
+
+BottomTabBar.displayName = 'BottomTabBar';
+
+export default BottomTabBar;
diff --git a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/TopBar.tsx b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/TopBar.tsx
new file mode 100644
index 000000000000..4ed8869c1eaa
--- /dev/null
+++ b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/TopBar.tsx
@@ -0,0 +1,36 @@
+import React from 'react';
+import {View} from 'react-native';
+import Search from '@components/Search';
+import WorkspaceSwitcherButton from '@components/WorkspaceSwitcherButton';
+import useActiveWorkspace from '@hooks/useActiveWorkspace';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import Navigation from '@libs/Navigation/Navigation';
+import SignInOrAvatarWithOptionalStatus from '@pages/home/sidebar/SignInOrAvatarWithOptionalStatus';
+import * as Session from '@userActions/Session';
+import ROUTES from '@src/ROUTES';
+
+function TopBar() {
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+ const {activeWorkspaceID} = useActiveWorkspace();
+
+ return (
+
+
+ Navigation.navigate(ROUTES.SEARCH))}
+ containerStyle={[styles.flex1]}
+ />
+
+
+ );
+}
+
+TopBar.displayName = 'TopBar';
+
+export default TopBar;
diff --git a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/index.tsx b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/index.tsx
new file mode 100644
index 000000000000..bd32c6cab73c
--- /dev/null
+++ b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/index.tsx
@@ -0,0 +1,73 @@
+import type {DefaultNavigatorOptions, ParamListBase, StackActionHelpers, StackNavigationState, StackRouterOptions} from '@react-navigation/native';
+import {createNavigatorFactory, StackRouter, useNavigationBuilder} from '@react-navigation/native';
+import type {StackNavigationEventMap, StackNavigationOptions} from '@react-navigation/stack';
+import {StackView} from '@react-navigation/stack';
+import React from 'react';
+import {View} from 'react-native';
+import ScreenWrapper from '@components/ScreenWrapper';
+import useThemeStyles from '@hooks/useThemeStyles';
+import type {NavigationStateRoute} from '@libs/Navigation/types';
+import SCREENS from '@src/SCREENS';
+import BottomTabBar from './BottomTabBar';
+import TopBar from './TopBar';
+
+type CustomNavigatorProps = DefaultNavigatorOptions, StackNavigationOptions, StackNavigationEventMap> & {
+ initialRouteName: string;
+};
+
+function getStateToRender(state: StackNavigationState): StackNavigationState {
+ const routesToRender = [state.routes.at(-1)] as NavigationStateRoute[];
+
+ // We need to render at least one HOME screen to make sure everything load properly. This may be not necessary after changing how IS_SIDEBAR_LOADED is handled.
+ // Currently this value will be switched only after the first HOME screen is rendered.
+ if (routesToRender[0].name !== SCREENS.HOME) {
+ const routeToRender = state.routes.find((route) => route.name === SCREENS.HOME);
+ if (routeToRender) {
+ routesToRender.unshift(routeToRender);
+ }
+ }
+
+ return {...state, routes: routesToRender, index: routesToRender.length - 1};
+}
+
+function CustomBottomTabNavigator({initialRouteName, children, screenOptions, ...props}: CustomNavigatorProps) {
+ const {state, navigation, descriptors, NavigationContent} = useNavigationBuilder<
+ StackNavigationState,
+ StackRouterOptions,
+ StackActionHelpers,
+ StackNavigationOptions,
+ StackNavigationEventMap
+ >(StackRouter, {
+ children,
+ screenOptions,
+ initialRouteName,
+ });
+
+ const styles = useThemeStyles();
+ const stateToRender = getStateToRender(state);
+
+ return (
+
+
+
+
+
+
+
+
+
+ );
+}
+
+CustomBottomTabNavigator.displayName = 'CustomBottomTabNavigator';
+
+export default createNavigatorFactory(CustomBottomTabNavigator);
diff --git a/src/libs/Navigation/AppNavigator/createCustomFullScreenNavigator/CustomFullScreenRouter.tsx b/src/libs/Navigation/AppNavigator/createCustomFullScreenNavigator/CustomFullScreenRouter.tsx
new file mode 100644
index 000000000000..b07c12d37b5a
--- /dev/null
+++ b/src/libs/Navigation/AppNavigator/createCustomFullScreenNavigator/CustomFullScreenRouter.tsx
@@ -0,0 +1,76 @@
+import type {ParamListBase, PartialState, RouterConfigOptions, StackNavigationState} from '@react-navigation/native';
+import {StackRouter} from '@react-navigation/native';
+import getIsSmallScreenWidth from '@libs/getIsSmallScreenWidth';
+import SCREENS from '@src/SCREENS';
+import type {FullScreenNavigatorRouterOptions} from './types';
+
+type StackState = StackNavigationState | PartialState>;
+
+const isAtLeastOneInState = (state: StackState, screenName: string): boolean => !!state.routes.find((route) => route.name === screenName);
+
+function adaptStateIfNecessary(state: StackState) {
+ const isSmallScreenWidth = getIsSmallScreenWidth();
+ // If the screen is wide, there should be at least two screens inside:
+ // - SETINGS.ROOT to cover left pane.
+ // - SETTINGS_CENTRAL_PANE to cover central pane.
+ if (!isSmallScreenWidth) {
+ if (!isAtLeastOneInState(state, SCREENS.SETTINGS.ROOT)) {
+ // @ts-expect-error Updating read only property
+ // noinspection JSConstantReassignment
+ state.stale = true; // eslint-disable-line
+
+ // This is necessary for ts to narrow type down to PartialState.
+ if (state.stale === true) {
+ // Unshift the root screen to fill left pane.
+ state.routes.unshift({name: SCREENS.SETTINGS.ROOT});
+ }
+ }
+ if (!isAtLeastOneInState(state, SCREENS.SETTINGS_CENTRAL_PANE)) {
+ // @ts-expect-error Updating read only property
+ // noinspection JSConstantReassignment
+ state.stale = true; // eslint-disable-line
+
+ // Push the default settings central pane screen.
+ if (state.stale === true) {
+ state.routes.push({
+ name: SCREENS.SETTINGS_CENTRAL_PANE,
+ state: {
+ routes: [
+ {
+ name: SCREENS.SETTINGS.PROFILE.ROOT,
+ },
+ ],
+ },
+ });
+ }
+ }
+ // eslint-disable-next-line no-param-reassign, @typescript-eslint/non-nullable-type-assertion-style
+ (state.index as number) = state.routes.length - 1;
+ }
+}
+
+function CustomFullScreenRouter(options: FullScreenNavigatorRouterOptions) {
+ const stackRouter = StackRouter(options);
+
+ return {
+ ...stackRouter,
+ getInitialState({routeNames, routeParamList, routeGetIdList}: RouterConfigOptions) {
+ const initialState = stackRouter.getInitialState({routeNames, routeParamList, routeGetIdList});
+ adaptStateIfNecessary(initialState);
+
+ // If we needed to modify the state we need to rehydrate it to get keys for new routes.
+ if (initialState.stale) {
+ return stackRouter.getRehydratedState(initialState, {routeNames, routeParamList, routeGetIdList});
+ }
+
+ return initialState;
+ },
+ getRehydratedState(partialState: StackState, {routeNames, routeParamList, routeGetIdList}: RouterConfigOptions): StackNavigationState {
+ adaptStateIfNecessary(partialState);
+ const state = stackRouter.getRehydratedState(partialState, {routeNames, routeParamList, routeGetIdList});
+ return state;
+ },
+ };
+}
+
+export default CustomFullScreenRouter;
diff --git a/src/libs/Navigation/AppNavigator/createCustomFullScreenNavigator/index.native.tsx b/src/libs/Navigation/AppNavigator/createCustomFullScreenNavigator/index.native.tsx
new file mode 100644
index 000000000000..2f61f1519df0
--- /dev/null
+++ b/src/libs/Navigation/AppNavigator/createCustomFullScreenNavigator/index.native.tsx
@@ -0,0 +1,36 @@
+import type {ParamListBase, StackActionHelpers, StackNavigationState} from '@react-navigation/native';
+import {createNavigatorFactory, useNavigationBuilder} from '@react-navigation/native';
+import type {StackNavigationEventMap, StackNavigationOptions} from '@react-navigation/stack';
+import {StackView} from '@react-navigation/stack';
+import CustomFullScreenRouter from './CustomFullScreenRouter';
+import type {FullScreenNavigatorProps, FullScreenNavigatorRouterOptions} from './types';
+
+function CustomFullScreenNavigator(props: FullScreenNavigatorProps) {
+ const {navigation, state, descriptors, NavigationContent} = useNavigationBuilder<
+ StackNavigationState,
+ FullScreenNavigatorRouterOptions,
+ StackActionHelpers,
+ StackNavigationOptions,
+ StackNavigationEventMap
+ >(CustomFullScreenRouter, {
+ children: props.children,
+ screenOptions: props.screenOptions,
+ initialRouteName: props.initialRouteName,
+ });
+
+ return (
+
+
+
+ );
+}
+
+CustomFullScreenNavigator.displayName = 'CustomFullScreenNavigator';
+
+export default createNavigatorFactory, StackNavigationOptions, StackNavigationEventMap, typeof CustomFullScreenNavigator>(CustomFullScreenNavigator);
diff --git a/src/libs/Navigation/AppNavigator/createCustomFullScreenNavigator/index.tsx b/src/libs/Navigation/AppNavigator/createCustomFullScreenNavigator/index.tsx
new file mode 100644
index 000000000000..fb7ae24947c2
--- /dev/null
+++ b/src/libs/Navigation/AppNavigator/createCustomFullScreenNavigator/index.tsx
@@ -0,0 +1,82 @@
+import type {ParamListBase, StackActionHelpers, StackNavigationState} from '@react-navigation/native';
+import {createNavigatorFactory, useNavigationBuilder} from '@react-navigation/native';
+import type {StackNavigationEventMap, StackNavigationOptions} from '@react-navigation/stack';
+import {StackView} from '@react-navigation/stack';
+import React, {useEffect, useMemo} from 'react';
+import useWindowDimensions from '@hooks/useWindowDimensions';
+import navigationRef from '@libs/Navigation/navigationRef';
+import SCREENS from '@src/SCREENS';
+import CustomFullScreenRouter from './CustomFullScreenRouter';
+import type {FullScreenNavigatorProps, FullScreenNavigatorRouterOptions} from './types';
+
+type Routes = StackNavigationState['routes'];
+function reduceReportRoutes(routes: Routes): Routes {
+ const result: Routes = [];
+ let count = 0;
+ const reverseRoutes = [...routes].reverse();
+
+ reverseRoutes.forEach((route) => {
+ if (route.name === SCREENS.SETTINGS_CENTRAL_PANE) {
+ // Remove all report routes except the last 3. This will improve performance.
+ if (count < 3) {
+ result.push(route);
+ count++;
+ }
+ } else {
+ result.push(route);
+ }
+ });
+
+ return result.reverse();
+}
+
+function CustomFullScreenNavigator(props: FullScreenNavigatorProps) {
+ const {navigation, state, descriptors, NavigationContent} = useNavigationBuilder<
+ StackNavigationState,
+ FullScreenNavigatorRouterOptions,
+ StackActionHelpers,
+ StackNavigationOptions,
+ StackNavigationEventMap
+ >(CustomFullScreenRouter, {
+ children: props.children,
+ screenOptions: props.screenOptions,
+ initialRouteName: props.initialRouteName,
+ });
+
+ const {isSmallScreenWidth} = useWindowDimensions();
+
+ const stateToRender = useMemo(() => {
+ const result = reduceReportRoutes(state.routes);
+
+ return {
+ ...state,
+ index: result.length - 1,
+ routes: [...result],
+ };
+ }, [state]);
+
+ useEffect(() => {
+ if (!navigationRef.isReady()) {
+ return;
+ }
+ // We need to separately reset state of this navigator to trigger getRehydratedState.
+ navigation.reset(navigation.getState());
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [isSmallScreenWidth]);
+
+ return (
+
+
+
+ );
+}
+
+CustomFullScreenNavigator.displayName = 'CustomFullScreenNavigator';
+
+export default createNavigatorFactory, StackNavigationOptions, StackNavigationEventMap, typeof CustomFullScreenNavigator>(CustomFullScreenNavigator);
diff --git a/src/libs/Navigation/AppNavigator/createCustomFullScreenNavigator/types.ts b/src/libs/Navigation/AppNavigator/createCustomFullScreenNavigator/types.ts
new file mode 100644
index 000000000000..7e7808c003d7
--- /dev/null
+++ b/src/libs/Navigation/AppNavigator/createCustomFullScreenNavigator/types.ts
@@ -0,0 +1,8 @@
+import type {DefaultNavigatorOptions, ParamListBase, StackNavigationState, StackRouterOptions} from '@react-navigation/native';
+import type {StackNavigationEventMap, StackNavigationOptions} from '@react-navigation/stack';
+
+type FullScreenNavigatorRouterOptions = StackRouterOptions;
+
+type FullScreenNavigatorProps = DefaultNavigatorOptions, StackNavigationOptions, StackNavigationEventMap>;
+
+export type {FullScreenNavigatorProps, FullScreenNavigatorRouterOptions};
diff --git a/src/libs/Navigation/AppNavigator/createCustomStackNavigator/CustomRouter.ts b/src/libs/Navigation/AppNavigator/createCustomStackNavigator/CustomRouter.ts
index 0ca5417e9f6e..a59150019142 100644
--- a/src/libs/Navigation/AppNavigator/createCustomStackNavigator/CustomRouter.ts
+++ b/src/libs/Navigation/AppNavigator/createCustomStackNavigator/CustomRouter.ts
@@ -1,89 +1,108 @@
-import type {NavigationState, PartialState, RouterConfigOptions, StackNavigationState} from '@react-navigation/native';
-import {StackRouter} from '@react-navigation/native';
+import type {RouterConfigOptions, StackNavigationState} from '@react-navigation/native';
+import {getPathFromState, StackRouter} from '@react-navigation/native';
import type {ParamListBase} from '@react-navigation/routers';
-import CONST from '@src/CONST';
+import getIsSmallScreenWidth from '@libs/getIsSmallScreenWidth';
+import getTopmostBottomTabRoute from '@libs/Navigation/getTopmostBottomTabRoute';
+import getTopmostCentralPaneRoute from '@libs/Navigation/getTopmostCentralPaneRoute';
+import linkingConfig from '@libs/Navigation/linkingConfig';
+import getAdaptedStateFromPath from '@libs/Navigation/linkingConfig/getAdaptedStateFromPath';
+import type {NavigationPartialRoute, RootStackParamList, State} from '@libs/Navigation/types';
import NAVIGATORS from '@src/NAVIGATORS';
import SCREENS from '@src/SCREENS';
import type {ResponsiveStackNavigatorRouterOptions} from './types';
-type State = NavigationState | PartialState;
+function insertRootRoute(state: State, routeToInsert: NavigationPartialRoute) {
+ const nonModalRoutes = state.routes.filter((route) => route.name !== NAVIGATORS.RIGHT_MODAL_NAVIGATOR && route.name !== NAVIGATORS.LEFT_MODAL_NAVIGATOR);
+ const modalRoutes = state.routes.filter((route) => route.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR || route.name === NAVIGATORS.LEFT_MODAL_NAVIGATOR);
-const isAtLeastOneCentralPaneNavigatorInState = (state: State): boolean => !!state.routes.find((route) => route.name === NAVIGATORS.CENTRAL_PANE_NAVIGATOR);
+ // It's safe to modify this state before returning in getRehydratedState.
-const getTopMostReportIDFromRHP = (state: State): string => {
- if (!state) {
- return '';
- }
+ // @ts-expect-error Updating read only property
+ // noinspection JSConstantReassignment
+ state.routes = [...nonModalRoutes, routeToInsert, ...modalRoutes]; // eslint-disable-line
- const topmostRightPane = state.routes.filter((route) => route.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR).at(-1);
+ // @ts-expect-error Updating read only property
+ // noinspection JSConstantReassignment
+ state.index = state.routes.length - 1; // eslint-disable-line
- if (topmostRightPane?.state) {
- return getTopMostReportIDFromRHP(topmostRightPane.state);
- }
+ // @ts-expect-error Updating read only property
+ // noinspection JSConstantReassignment
+ state.stale = true; // eslint-disable-line
+}
- const topmostRoute = state.routes.at(-1);
-
- // In the case of money requests, send money and split bill,
- // we want to ignore the associated report and fall back to the default navigation behavior
- if (
- topmostRoute?.params &&
- 'iouType' in topmostRoute.params &&
- typeof topmostRoute.params.iouType === 'string' &&
- (topmostRoute.params.iouType === CONST.IOU.TYPE.REQUEST || topmostRoute.params.iouType === CONST.IOU.TYPE.SEND || topmostRoute.params.iouType === CONST.IOU.TYPE.SPLIT)
- ) {
- return '';
+function compareAndAdaptState(state: StackNavigationState) {
+ // If the state of the last path is not defined the getPathFromState won't work correctly.
+ if (!state?.routes.at(-1)?.state) {
+ return;
}
- if (topmostRoute?.state) {
- return getTopMostReportIDFromRHP(topmostRoute.state);
- }
+ // We need to be sure that the bottom tab state is defined.
+ const topmostBottomTabRoute = getTopmostBottomTabRoute(state);
+ const isSmallScreenWidth = getIsSmallScreenWidth();
- if (topmostRoute?.params && 'reportID' in topmostRoute.params && typeof topmostRoute.params.reportID === 'string' && topmostRoute.params.reportID) {
- return topmostRoute.params.reportID;
- }
+ // This solutions is heuristics and will work for our cases. We may need to improve it in the future if we will have more cases to handle.
+ if (topmostBottomTabRoute && !isSmallScreenWidth) {
+ const fullScreenRoute = state.routes.find((route) => route.name === NAVIGATORS.FULL_SCREEN_NAVIGATOR);
- return '';
-};
-/**
- * Adds report route without any specific reportID to the state.
- * The report screen will self set proper reportID param based on the helper function findLastAccessedReport (look at ReportScreenWrapper for more info)
- *
- * @param state - react-navigation state
- */
-const addCentralPaneNavigatorRoute = (state: State) => {
- const reportID = getTopMostReportIDFromRHP(state);
- const centralPaneNavigatorRoute = {
- name: NAVIGATORS.CENTRAL_PANE_NAVIGATOR,
- state: {
- routes: [
- {
- name: SCREENS.REPORT,
- params: {
- reportID,
- },
- },
- ],
- },
- };
- state.routes.splice(1, 0, centralPaneNavigatorRoute);
- // eslint-disable-next-line no-param-reassign, @typescript-eslint/non-nullable-type-assertion-style
- (state.index as number) = state.routes.length - 1;
-};
+ // If there is fullScreenRoute we don't need to add anything.
+ if (fullScreenRoute) {
+ return;
+ }
+
+ // We will generate a template state and compare the current state with it.
+ // If there is a difference in the screens that should be visible under the overlay, we will add the screen from templateState to the current state.
+ const pathFromCurrentState = getPathFromState(state, linkingConfig.config);
+ const {adaptedState: templateState} = getAdaptedStateFromPath(pathFromCurrentState, linkingConfig.config);
+
+ if (!templateState) {
+ return;
+ }
+
+ const templateFullScreenRoute = templateState.routes.find((route) => route.name === NAVIGATORS.FULL_SCREEN_NAVIGATOR);
+
+ // If templateFullScreenRoute is defined, and full screen route is not in the state, we need to add it.
+ if (templateFullScreenRoute) {
+ insertRootRoute(state, templateFullScreenRoute);
+ return;
+ }
+
+ const topmostCentralPaneRoute = state.routes.filter((route) => route.name === NAVIGATORS.CENTRAL_PANE_NAVIGATOR).at(-1);
+ const templateCentralPaneRoute = templateState.routes.find((route) => route.name === NAVIGATORS.CENTRAL_PANE_NAVIGATOR);
+
+ const topmostCentralPaneRouteExtracted = getTopmostCentralPaneRoute(state);
+ const templateCentralPaneRouteExtracted = getTopmostCentralPaneRoute(templateState as State);
+
+ // If there is no templateCentralPaneRoute, we don't have anything to add.
+ if (!templateCentralPaneRoute) {
+ return;
+ }
+
+ // If there is no topmostCentralPaneRoute in the state and template state has one, we need to add it.
+ if (!topmostCentralPaneRoute) {
+ insertRootRoute(state, templateCentralPaneRoute);
+ return;
+ }
+
+ // If there is central pane route in state and template state has one, we need to check if they are the same.
+ if (topmostCentralPaneRouteExtracted && templateCentralPaneRouteExtracted && topmostCentralPaneRouteExtracted.name !== templateCentralPaneRouteExtracted.name) {
+ // Not every RHP screen has matching central pane defined. In that case we use the REPORT screen as default for initial screen.
+ // But we don't want to override the central pane for those screens as they may be opened with different central panes under the overlay.
+ // e.g. i-know-a-teacher may be opened with different central panes under the overlay
+ if (templateCentralPaneRouteExtracted.name === SCREENS.REPORT) {
+ return;
+ }
+ insertRootRoute(state, templateCentralPaneRoute);
+ }
+ }
+}
function CustomRouter(options: ResponsiveStackNavigatorRouterOptions) {
const stackRouter = StackRouter(options);
return {
...stackRouter,
- getRehydratedState(partialState: StackNavigationState, {routeNames, routeParamList, routeGetIdList}: RouterConfigOptions): StackNavigationState {
- // Make sure that there is at least one CentralPaneNavigator (ReportScreen by default) in the state if this is a wide layout
- if (!isAtLeastOneCentralPaneNavigatorInState(partialState) && !options.getIsSmallScreenWidth()) {
- // If we added a route we need to make sure that the state.stale is true to generate new key for this route
-
- // eslint-disable-next-line no-param-reassign
- (partialState.stale as boolean) = true;
- addCentralPaneNavigatorRoute(partialState);
- }
+ getRehydratedState(partialState: StackNavigationState, {routeNames, routeParamList, routeGetIdList}: RouterConfigOptions): StackNavigationState {
+ compareAndAdaptState(partialState);
const state = stackRouter.getRehydratedState(partialState, {routeNames, routeParamList, routeGetIdList});
return state;
},
diff --git a/src/libs/Navigation/AppNavigator/createCustomStackNavigator/index.native.tsx b/src/libs/Navigation/AppNavigator/createCustomStackNavigator/index.native.tsx
index 5f1010da2ed1..151dd0a0f893 100644
--- a/src/libs/Navigation/AppNavigator/createCustomStackNavigator/index.native.tsx
+++ b/src/libs/Navigation/AppNavigator/createCustomStackNavigator/index.native.tsx
@@ -24,8 +24,6 @@ function ResponsiveStackNavigator(props: ResponsiveStackNavigatorProps) {
children: props.children,
screenOptions: props.screenOptions,
initialRouteName: props.initialRouteName,
- // Options for useNavigationBuilder won't update on prop change, so we need to pass a getter for the router to have the current state of isSmallScreenWidth.
- getIsSmallScreenWidth: () => isSmallScreenWidthRef.current,
});
return (
diff --git a/src/libs/Navigation/AppNavigator/createCustomStackNavigator/index.tsx b/src/libs/Navigation/AppNavigator/createCustomStackNavigator/index.tsx
index 06845e6e6f61..2a517c45eb0d 100644
--- a/src/libs/Navigation/AppNavigator/createCustomStackNavigator/index.tsx
+++ b/src/libs/Navigation/AppNavigator/createCustomStackNavigator/index.tsx
@@ -2,8 +2,9 @@ import type {ParamListBase, StackActionHelpers, StackNavigationState} from '@rea
import {createNavigatorFactory, useNavigationBuilder} from '@react-navigation/native';
import type {StackNavigationEventMap, StackNavigationOptions} from '@react-navigation/stack';
import {StackView} from '@react-navigation/stack';
-import React, {useMemo, useRef} from 'react';
+import React, {useEffect, useMemo} from 'react';
import useWindowDimensions from '@hooks/useWindowDimensions';
+import navigationRef from '@libs/Navigation/navigationRef';
import NAVIGATORS from '@src/NAVIGATORS';
import CustomRouter from './CustomRouter';
import type {ResponsiveStackNavigatorProps, ResponsiveStackNavigatorRouterOptions} from './types';
@@ -32,10 +33,6 @@ function reduceReportRoutes(routes: Routes): Routes {
function ResponsiveStackNavigator(props: ResponsiveStackNavigatorProps) {
const {isSmallScreenWidth} = useWindowDimensions();
- const isSmallScreenWidthRef = useRef(isSmallScreenWidth);
-
- isSmallScreenWidthRef.current = isSmallScreenWidth;
-
const {navigation, state, descriptors, NavigationContent} = useNavigationBuilder<
StackNavigationState,
ResponsiveStackNavigatorRouterOptions,
@@ -46,10 +43,15 @@ function ResponsiveStackNavigator(props: ResponsiveStackNavigatorProps) {
children: props.children,
screenOptions: props.screenOptions,
initialRouteName: props.initialRouteName,
- // Options for useNavigationBuilder won't update on prop change, so we need to pass a getter for the router to have the current state of isSmallScreenWidth.
- getIsSmallScreenWidth: () => isSmallScreenWidthRef.current,
});
+ useEffect(() => {
+ if (!navigationRef.isReady()) {
+ return;
+ }
+ navigationRef.resetRoot(navigationRef.getRootState());
+ }, [isSmallScreenWidth]);
+
const stateToRender = useMemo(() => {
const result = reduceReportRoutes(state.routes);
diff --git a/src/libs/Navigation/AppNavigator/createCustomStackNavigator/types.ts b/src/libs/Navigation/AppNavigator/createCustomStackNavigator/types.ts
index ea94fed19b11..09d35e2a1680 100644
--- a/src/libs/Navigation/AppNavigator/createCustomStackNavigator/types.ts
+++ b/src/libs/Navigation/AppNavigator/createCustomStackNavigator/types.ts
@@ -5,9 +5,7 @@ type ResponsiveStackNavigatorConfig = {
isSmallScreenWidth: boolean;
};
-type ResponsiveStackNavigatorRouterOptions = StackRouterOptions & {
- getIsSmallScreenWidth: () => boolean;
-};
+type ResponsiveStackNavigatorRouterOptions = StackRouterOptions;
type ResponsiveStackNavigatorProps = DefaultNavigatorOptions, StackNavigationOptions, StackNavigationEventMap> &
ResponsiveStackNavigatorConfig;
diff --git a/src/libs/Navigation/AppNavigator/getActionsFromPartialDiff.ts b/src/libs/Navigation/AppNavigator/getActionsFromPartialDiff.ts
new file mode 100644
index 000000000000..1d0e90c041be
--- /dev/null
+++ b/src/libs/Navigation/AppNavigator/getActionsFromPartialDiff.ts
@@ -0,0 +1,43 @@
+import {getActionFromState, StackActions} from '@react-navigation/native';
+import type {NavigationAction} from '@react-navigation/native';
+import linkingConfig from '@libs/Navigation/linkingConfig';
+import NAVIGATORS from '@src/NAVIGATORS';
+import type {GetPartialStateDiffReturnType} from './getPartialStateDiff';
+
+/**
+ * @param diff - Diff generated by getPartialDiff.
+ * @returns Array of actions to dispatch to apply diff.
+ */
+function getActionsFromPartialDiff(diff: GetPartialStateDiffReturnType): NavigationAction[] {
+ const actions: NavigationAction[] = [];
+
+ const bottomTabDiff = diff[NAVIGATORS.BOTTOM_TAB_NAVIGATOR];
+ const centralPaneDiff = diff[NAVIGATORS.CENTRAL_PANE_NAVIGATOR];
+ const fullScreenDiff = diff[NAVIGATORS.FULL_SCREEN_NAVIGATOR];
+
+ // There is only one bottom tab navigator so we can just push this route.
+ if (bottomTabDiff) {
+ actions.push(StackActions.push(bottomTabDiff.name, bottomTabDiff.params));
+ }
+
+ if (centralPaneDiff) {
+ // In this case we have to wrap the inner central pane route with central pane navigator.
+ actions.push(
+ StackActions.push(NAVIGATORS.CENTRAL_PANE_NAVIGATOR, {
+ screen: centralPaneDiff.name,
+ params: centralPaneDiff.params,
+ }),
+ );
+ }
+
+ if (fullScreenDiff) {
+ const action = getActionFromState({routes: [fullScreenDiff]}, linkingConfig.config);
+ if (action) {
+ actions.push(action);
+ }
+ }
+
+ return actions;
+}
+
+export default getActionsFromPartialDiff;
diff --git a/src/libs/Navigation/AppNavigator/getPartialStateDiff.ts b/src/libs/Navigation/AppNavigator/getPartialStateDiff.ts
new file mode 100644
index 000000000000..a22185422a11
--- /dev/null
+++ b/src/libs/Navigation/AppNavigator/getPartialStateDiff.ts
@@ -0,0 +1,87 @@
+import getTopmostBottomTabRoute from '@libs/Navigation/getTopmostBottomTabRoute';
+import getTopmostCentralPaneRoute from '@libs/Navigation/getTopmostCentralPaneRoute';
+import type {Metainfo} from '@libs/Navigation/linkingConfig/getAdaptedStateFromPath';
+import type {NavigationPartialRoute, RootStackParamList, State} from '@libs/Navigation/types';
+import NAVIGATORS from '@src/NAVIGATORS';
+
+// eslint-disable-next-line @typescript-eslint/ban-types
+const shallowCompare = (obj1?: object, obj2?: object) => {
+ if (!obj1 && !obj2) {
+ return true;
+ }
+ if (obj1 && obj2) {
+ // @ts-expect-error we know that obj1 and obj2 are params of a route.
+ return Object.keys(obj1).length === Object.keys(obj2).length && Object.keys(obj1).every((key) => obj1[key] === obj2[key]);
+ }
+ return false;
+};
+
+type GetPartialStateDiffReturnType = {
+ [NAVIGATORS.BOTTOM_TAB_NAVIGATOR]?: NavigationPartialRoute;
+ [NAVIGATORS.CENTRAL_PANE_NAVIGATOR]?: NavigationPartialRoute;
+ [NAVIGATORS.FULL_SCREEN_NAVIGATOR]?: NavigationPartialRoute;
+};
+
+/**
+ * This function returns partial additive diff between the two states.
+ *
+ * Example: Let's start with state A on route /r/123. If the screen is wide we will have a HOME opened on bottom tab and REPORT on central pane.
+ * Now let's say we want to navigate to /workspace/345/overview. We will generate state B from this path.
+ * State B will have WORKSPACE_INITIAL on the bottom tab and WORKSPACE_OVERVIEW on the central pane.
+ * Now we will generate partial diff between state A and state B. The diff will tell us that we need to push WORKSPACE_INITIAL on the bottom tab and WORKSPACE_OVERVIEW on the central pane.
+ *
+ * Then we can generate actions from this diff and dispatch them to the linkTo function.
+ *
+ * It's named partial diff because we don't cover RHP and LHP navigators yet. In the future we can improve this function to handle all navigators to help us clean and simplify the linkTo function.
+ *
+ * The partial diff has information which bottom tab, central pane and full screen screens we need to push to go from state to templateState.
+ * @param state - Current state.
+ * @param templateState - Desired state generated with getAdaptedStateFromPath.
+ * @param metainfo - Additional info from getAdaptedStateFromPath function.
+ * @returns The screen options object
+ */
+function getPartialStateDiff(state: State, templateState: State, metainfo: Metainfo): GetPartialStateDiffReturnType {
+ const diff: GetPartialStateDiffReturnType = {};
+
+ // If it is mandatory we need to compare both central pane and bottom tab of states.
+ if (metainfo.isCentralPaneAndBottomTabMandatory) {
+ const stateTopmostBottomTab = getTopmostBottomTabRoute(state);
+ const templateStateTopmostBottomTab = getTopmostBottomTabRoute(templateState);
+
+ // Bottom tab navigator
+ if (stateTopmostBottomTab && templateStateTopmostBottomTab && stateTopmostBottomTab.name !== templateStateTopmostBottomTab.name) {
+ diff[NAVIGATORS.BOTTOM_TAB_NAVIGATOR] = templateStateTopmostBottomTab;
+ }
+
+ const stateTopmostCentralPane = getTopmostCentralPaneRoute(state);
+ const templateStateTopmostCentralPane = getTopmostCentralPaneRoute(templateState);
+
+ if (
+ // If the central pane is only in the template state, it's diff.
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ (!stateTopmostCentralPane && templateStateTopmostCentralPane) ||
+ (stateTopmostCentralPane &&
+ templateStateTopmostCentralPane &&
+ stateTopmostCentralPane.name !== templateStateTopmostCentralPane.name &&
+ !shallowCompare(stateTopmostCentralPane.params, templateStateTopmostCentralPane.params))
+ ) {
+ // We need to wrap central pane routes in the central pane navigator.
+ diff[NAVIGATORS.CENTRAL_PANE_NAVIGATOR] = templateStateTopmostCentralPane;
+ }
+ }
+
+ // This one is heuristic and may need to be improved if we will be able to navigate from modal screen with full screen in background to another modal screen with full screen in background.
+ // For now this simple check is enough.
+ if (metainfo.isFullScreenNavigatorMandatory) {
+ const stateTopmostFullScreen = state.routes.filter((route) => route.name === NAVIGATORS.FULL_SCREEN_NAVIGATOR).at(-1);
+ const templateStateTopmostFullScreen = templateState.routes.filter((route) => route.name === NAVIGATORS.FULL_SCREEN_NAVIGATOR).at(-1) as NavigationPartialRoute;
+ if (!stateTopmostFullScreen && templateStateTopmostFullScreen) {
+ diff[NAVIGATORS.FULL_SCREEN_NAVIGATOR] = templateStateTopmostFullScreen;
+ }
+ }
+
+ return diff;
+}
+
+export default getPartialStateDiff;
+export type {GetPartialStateDiffReturnType};
diff --git a/src/libs/Navigation/AppNavigator/getRootNavigatorScreenOptions.ts b/src/libs/Navigation/AppNavigator/getRootNavigatorScreenOptions.ts
index 31b8d49e74c0..c3a69bbd7ccf 100644
--- a/src/libs/Navigation/AppNavigator/getRootNavigatorScreenOptions.ts
+++ b/src/libs/Navigation/AppNavigator/getRootNavigatorScreenOptions.ts
@@ -93,6 +93,20 @@ const getRootNavigatorScreenOptions: GetRootNavigatorScreenOptions = (isSmallScr
paddingRight: isSmallScreenWidth ? 0 : variables.sideBarWidth,
},
},
+
+ bottomTab: {
+ ...commonScreenOptions,
+ cardStyleInterpolator: (props: StackCardInterpolationProps) => modalCardStyleInterpolator(isSmallScreenWidth, false, props),
+
+ cardStyle: {
+ ...StyleUtils.getNavigationModalCardStyle(),
+ width: isSmallScreenWidth ? '100%' : variables.sideBarWidth,
+
+ // We need to shift the sidebar to not be covered by the StackNavigator so it can be clickable.
+ marginLeft: isSmallScreenWidth ? 0 : -variables.sideBarWidth,
+ ...(isSmallScreenWidth ? {} : themeStyles.borderRight),
+ },
+ },
};
};
diff --git a/src/libs/Navigation/Navigation.ts b/src/libs/Navigation/Navigation.ts
index 83890848b8f7..188d8b5f337f 100644
--- a/src/libs/Navigation/Navigation.ts
+++ b/src/libs/Navigation/Navigation.ts
@@ -1,19 +1,24 @@
import {findFocusedRoute} from '@react-navigation/core';
-import type {EventArg, NavigationContainerEventMap, NavigationState, PartialState} from '@react-navigation/native';
+import type {EventArg, NavigationContainerEventMap} from '@react-navigation/native';
import {CommonActions, getPathFromState, StackActions} from '@react-navigation/native';
import Log from '@libs/Log';
+import {getReport} from '@libs/ReportUtils';
import CONST from '@src/CONST';
import NAVIGATORS from '@src/NAVIGATORS';
import type {Route} from '@src/ROUTES';
import ROUTES from '@src/ROUTES';
import {PROTECTED_SCREENS} from '@src/SCREENS';
+import type {Report} from '@src/types/onyx';
+import type {EmptyObject} from '@src/types/utils/EmptyObject';
import originalDismissModal from './dismissModal';
+import originalDismissModalWithReport from './dismissModalWithReport';
import originalGetTopmostReportActionId from './getTopmostReportActionID';
import originalGetTopmostReportId from './getTopmostReportId';
import linkingConfig from './linkingConfig';
import linkTo from './linkTo';
import navigationRef from './navigationRef';
-import type {StateOrRoute} from './types';
+import switchPolicyID from './switchPolicyID';
+import type {State, StateOrRoute, SwitchPolicyIDParams} from './types';
let resolveNavigationIsReadyPromise: () => void;
const navigationIsReadyPromise = new Promise((resolve) => {
@@ -46,7 +51,19 @@ const getTopmostReportId = (state = navigationRef.getState()) => originalGetTopm
const getTopmostReportActionId = (state = navigationRef.getState()) => originalGetTopmostReportActionId(state);
// Re-exporting the dismissModal here to fill in default value for navigationRef. The dismissModal isn't defined in this file to avoid cyclic dependencies.
-const dismissModal = (targetReportId = '', ref = navigationRef) => originalDismissModal(targetReportId, ref);
+const dismissModal = (reportID?: string, ref = navigationRef) => {
+ if (!reportID) {
+ originalDismissModal(ref);
+ return;
+ }
+ const report = getReport(reportID);
+ originalDismissModalWithReport({reportID, ...report}, ref);
+};
+
+// Re-exporting the dismissModalWithReport here to fill in default value for navigationRef. The dismissModalWithReport isn't defined in this file to avoid cyclic dependencies.
+// This method is needed because it allows to dismiss the modal and then open the report. Within this method is checked whether the report belongs to a specific workspace. Sometimes the report we want to check, hasn't been added to the Onyx yet.
+// Then we can pass the report as a param without getting it from the Onyx.
+const dismissModalWithReport = (report: Report | EmptyObject, ref = navigationRef) => originalDismissModalWithReport(report, ref);
/** Method for finding on which index in stack we are. */
function getActiveRouteIndex(stateOrRoute: StateOrRoute, index?: number): number | undefined {
@@ -60,7 +77,10 @@ function getActiveRouteIndex(stateOrRoute: StateOrRoute, index?: number): number
return getActiveRouteIndex(childActiveRoute, stateOrRoute.state.index ?? 0);
}
- if ('name' in stateOrRoute && (stateOrRoute.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR || stateOrRoute.name === NAVIGATORS.LEFT_MODAL_NAVIGATOR)) {
+ if (
+ 'name' in stateOrRoute &&
+ (stateOrRoute.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR || stateOrRoute.name === NAVIGATORS.LEFT_MODAL_NAVIGATOR || stateOrRoute.name === NAVIGATORS.FULL_SCREEN_NAVIGATOR)
+ ) {
return 0;
}
@@ -201,6 +221,14 @@ function goBack(fallbackRoute?: Route, shouldEnforceFallback = false, shouldPopT
navigationRef.current.goBack();
}
+/**
+ * Close the full screen modal.
+ */
+function closeFullScreen() {
+ const rootState = navigationRef.getRootState();
+ navigationRef.dispatch({...StackActions.popToTop(), target: rootState.key});
+}
+
/**
* Update route params for the specified route.
*/
@@ -258,13 +286,13 @@ function setIsNavigationReady() {
*
* @param state - react-navigation state object
*/
-function navContainsProtectedRoutes(state: NavigationState | PartialState | undefined): boolean {
+function navContainsProtectedRoutes(state: State | undefined): boolean {
if (!state?.routeNames || !Array.isArray(state.routeNames)) {
return false;
}
- const protectedScreensName = Object.values(PROTECTED_SCREENS);
- return !protectedScreensName.some((screen) => !state.routeNames?.includes(screen));
+ // If one protected screen is in the routeNames then other screens are there as well.
+ return state?.routeNames.includes(PROTECTED_SCREENS.CONCIERGE);
}
/**
@@ -298,11 +326,20 @@ function waitForProtectedRoutes() {
});
}
+function navigateWithSwitchPolicyID(params: SwitchPolicyIDParams) {
+ if (!canNavigate('navigateWithSwitchPolicyID')) {
+ return;
+ }
+
+ return switchPolicyID(navigationRef.current, params);
+}
+
export default {
setShouldPopAllStateOnUP,
navigate,
setParams,
dismissModal,
+ dismissModalWithReport,
isActiveRoute,
getActiveRoute,
getActiveRouteWithoutParams,
@@ -313,6 +350,8 @@ export default {
getRouteNameFromStateEvent,
getTopmostReportActionId,
waitForProtectedRoutes,
+ closeFullScreen,
+ navigateWithSwitchPolicyID,
};
export {navigationRef};
diff --git a/src/libs/Navigation/NavigationRoot.tsx b/src/libs/Navigation/NavigationRoot.tsx
index 79ec18d28d4f..20c426a74c71 100644
--- a/src/libs/Navigation/NavigationRoot.tsx
+++ b/src/libs/Navigation/NavigationRoot.tsx
@@ -1,19 +1,34 @@
import type {NavigationState} from '@react-navigation/native';
-import {DefaultTheme, getPathFromState, NavigationContainer} from '@react-navigation/native';
+import {DefaultTheme, findFocusedRoute, NavigationContainer} from '@react-navigation/native';
import React, {useEffect, useMemo, useRef} from 'react';
+import useActiveWorkspace from '@hooks/useActiveWorkspace';
import useCurrentReportID from '@hooks/useCurrentReportID';
import useFlipper from '@hooks/useFlipper';
import useTheme from '@hooks/useTheme';
import useWindowDimensions from '@hooks/useWindowDimensions';
import Log from '@libs/Log';
+import {getPathFromURL} from '@libs/Url';
+import {updateLastVisitedPath} from '@userActions/App';
+import type {Route} from '@src/ROUTES';
+import SCREENS from '@src/SCREENS';
import AppNavigator from './AppNavigator';
+import getPolicyIDFromState from './getPolicyIDFromState';
import linkingConfig from './linkingConfig';
+import customGetPathFromState from './linkingConfig/customGetPathFromState';
+import getAdaptedStateFromPath from './linkingConfig/getAdaptedStateFromPath';
import Navigation, {navigationRef} from './Navigation';
+import type {RootStackParamList} from './types';
type NavigationRootProps = {
/** Whether the current user is logged in with an authToken */
authenticated: boolean;
+ /** Stores path of last visited page */
+ lastVisitedPath: Route;
+
+ /** Initial url */
+ initialUrl: string | null;
+
/** Fired when react-navigation is ready */
onReady: () => void;
};
@@ -26,7 +41,13 @@ function parseAndLogRoute(state: NavigationState) {
return;
}
- const currentPath = getPathFromState(state, linkingConfig.config);
+ const currentPath = customGetPathFromState(state, linkingConfig.config);
+
+ const focusedRoute = findFocusedRoute(state);
+
+ if (focusedRoute?.name !== SCREENS.NOT_FOUND) {
+ updateLastVisitedPath(currentPath);
+ }
// Don't log the route transitions from OldDot because they contain authTokens
if (currentPath.includes('/transition')) {
@@ -38,13 +59,34 @@ function parseAndLogRoute(state: NavigationState) {
Navigation.setIsNavigationReady();
}
-function NavigationRoot({authenticated, onReady}: NavigationRootProps) {
+function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady}: NavigationRootProps) {
useFlipper(navigationRef);
const firstRenderRef = useRef(true);
const theme = useTheme();
const currentReportIDValue = useCurrentReportID();
const {isSmallScreenWidth} = useWindowDimensions();
+ const {setActiveWorkspaceID} = useActiveWorkspace();
+
+ const initialState = useMemo(
+ () => {
+ if (!lastVisitedPath) {
+ return undefined;
+ }
+
+ const path = initialUrl ? getPathFromURL(initialUrl) : null;
+
+ // For non-nullable paths we don't want to set initial state
+ if (path) {
+ return;
+ }
+
+ const {adaptedState} = getAdaptedStateFromPath(lastVisitedPath, linkingConfig.config);
+ return adaptedState;
+ },
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [],
+ );
// https://reactnavigation.org/docs/themes
const navigationTheme = useMemo(
@@ -72,28 +114,22 @@ function NavigationRoot({authenticated, onReady}: NavigationRootProps) {
Navigation.setShouldPopAllStateOnUP();
}, [isSmallScreenWidth]);
- useEffect(() => {
- if (!navigationRef.isReady() || !authenticated) {
- return;
- }
- // We need to force state rehydration so the CustomRouter can add the CentralPaneNavigator route if necessary.
- navigationRef.resetRoot(navigationRef.getRootState());
- }, [isSmallScreenWidth, authenticated]);
-
const handleStateChange = (state: NavigationState | undefined) => {
if (!state) {
return;
}
-
+ const activeWorkspaceID = getPolicyIDFromState(state as NavigationState);
// Performance optimization to avoid context consumers to delay first render
setTimeout(() => {
currentReportIDValue?.updateCurrentReportID(state);
+ setActiveWorkspaceID(activeWorkspaceID);
}, 0);
parseAndLogRoute(state);
};
return (
) {
+function dismissModal(navigationRef: NavigationContainerRef) {
if (!navigationRef.isReady()) {
return;
}
@@ -26,27 +20,12 @@ function dismissModal(targetReportID: string, navigationRef: NavigationContainer
const state = navigationRef.getState();
const lastRoute = state.routes.at(-1);
switch (lastRoute?.name) {
+ case NAVIGATORS.FULL_SCREEN_NAVIGATOR:
case NAVIGATORS.LEFT_MODAL_NAVIGATOR:
case NAVIGATORS.RIGHT_MODAL_NAVIGATOR:
case SCREENS.NOT_FOUND:
case SCREENS.REPORT_ATTACHMENTS:
- // if we are not in the target report, we need to navigate to it after dismissing the modal
- if (targetReportID && targetReportID !== getTopmostReportId(state)) {
- const reportState = getStateFromPath(ROUTES.REPORT_WITH_ID.getRoute(targetReportID));
-
- const action: StackNavigationAction = getActionFromState(reportState, linkingConfig.config);
- if (action) {
- action.type = 'REPLACE';
- navigationRef.dispatch(action);
- }
- // If not-found page is in the route stack, we need to close it
- } else if (targetReportID && state.routes.some((route) => route.name === SCREENS.NOT_FOUND)) {
- const lastRouteIndex = state.routes.length - 1;
- const centralRouteIndex = findLastIndex(state.routes, (route) => route.name === NAVIGATORS.CENTRAL_PANE_NAVIGATOR);
- navigationRef.dispatch({...StackActions.pop(lastRouteIndex - centralRouteIndex), target: state.key});
- } else {
- navigationRef.dispatch({...StackActions.pop(), target: state.key});
- }
+ navigationRef.dispatch({...StackActions.pop(), target: state.key});
break;
default: {
Log.hmmm('[Navigation] dismissModal failed because there is no modal stack to dismiss');
diff --git a/src/libs/Navigation/dismissModalWithReport.ts b/src/libs/Navigation/dismissModalWithReport.ts
new file mode 100644
index 000000000000..b708819cdf9c
--- /dev/null
+++ b/src/libs/Navigation/dismissModalWithReport.ts
@@ -0,0 +1,74 @@
+import {getActionFromState} from '@react-navigation/core';
+import type {NavigationContainerRef} from '@react-navigation/native';
+import {StackActions} from '@react-navigation/native';
+import {findLastIndex} from 'lodash';
+import Log from '@libs/Log';
+import getPolicyMemberAccountIDs from '@libs/PolicyMembersUtils';
+import {doesReportBelongToWorkspace} from '@libs/ReportUtils';
+import NAVIGATORS from '@src/NAVIGATORS';
+import ROUTES from '@src/ROUTES';
+import SCREENS from '@src/SCREENS';
+import type {Report} from '@src/types/onyx';
+import type {EmptyObject} from '@src/types/utils/EmptyObject';
+import {isEmptyObject} from '@src/types/utils/EmptyObject';
+import getPolicyIDFromState from './getPolicyIDFromState';
+import getStateFromPath from './getStateFromPath';
+import getTopmostReportId from './getTopmostReportId';
+import linkingConfig from './linkingConfig';
+import switchPolicyID from './switchPolicyID';
+import type {RootStackParamList, StackNavigationAction, State} from './types';
+
+// This function is in a separate file than Navigation.ts to avoid cyclic dependency.
+
+/**
+ * Dismisses the last modal stack if there is any
+ *
+ * @param targetReportID - The reportID to navigate to after dismissing the modal
+ */
+function dismissModalWithReport(targetReport: Report | EmptyObject, navigationRef: NavigationContainerRef) {
+ if (!navigationRef.isReady()) {
+ return;
+ }
+
+ const state = navigationRef.getState();
+ const lastRoute = state.routes.at(-1);
+ switch (lastRoute?.name) {
+ case NAVIGATORS.FULL_SCREEN_NAVIGATOR:
+ case NAVIGATORS.LEFT_MODAL_NAVIGATOR:
+ case NAVIGATORS.RIGHT_MODAL_NAVIGATOR:
+ case SCREENS.NOT_FOUND:
+ case SCREENS.REPORT_ATTACHMENTS:
+ // If we are not in the target report, we need to navigate to it after dismissing the modal
+ if (targetReport.reportID !== getTopmostReportId(state)) {
+ const reportState = getStateFromPath(ROUTES.REPORT_WITH_ID.getRoute(targetReport.reportID));
+ const policyID = getPolicyIDFromState(state as State);
+ const policyMemberAccountIDs = getPolicyMemberAccountIDs(policyID);
+ const shouldOpenAllWorkspace = isEmptyObject(targetReport) ? true : !doesReportBelongToWorkspace(targetReport, policyMemberAccountIDs, policyID);
+
+ if (shouldOpenAllWorkspace) {
+ switchPolicyID(navigationRef, {route: ROUTES.HOME});
+ } else {
+ switchPolicyID(navigationRef, {policyID, route: ROUTES.HOME});
+ }
+
+ const action: StackNavigationAction = getActionFromState(reportState, linkingConfig.config);
+ if (action) {
+ action.type = 'REPLACE';
+ navigationRef.dispatch(action);
+ }
+ // If not-found page is in the route stack, we need to close it
+ } else if (state.routes.some((route) => route.name === SCREENS.NOT_FOUND)) {
+ const lastRouteIndex = state.routes.length - 1;
+ const centralRouteIndex = findLastIndex(state.routes, (route) => route.name === NAVIGATORS.CENTRAL_PANE_NAVIGATOR);
+ navigationRef.dispatch({...StackActions.pop(lastRouteIndex - centralRouteIndex), target: state.key});
+ } else {
+ navigationRef.dispatch({...StackActions.pop(), target: state.key});
+ }
+ break;
+ default: {
+ Log.hmmm('[Navigation] dismissModalWithReport failed because there is no modal stack to dismiss');
+ }
+ }
+}
+
+export default dismissModalWithReport;
diff --git a/src/libs/Navigation/getPolicyIDFromState.ts b/src/libs/Navigation/getPolicyIDFromState.ts
new file mode 100644
index 000000000000..00236fb0fce0
--- /dev/null
+++ b/src/libs/Navigation/getPolicyIDFromState.ts
@@ -0,0 +1,16 @@
+import getTopmostBottomTabRoute from './getTopmostBottomTabRoute';
+import type {RootStackParamList, State} from './types';
+
+const getPolicyIDFromState = (state: State): string | undefined => {
+ const topmostBottomTabRoute = getTopmostBottomTabRoute(state);
+
+ const shouldAddPolicyIDToUrl = !!topmostBottomTabRoute && !!topmostBottomTabRoute.params && 'policyID' in topmostBottomTabRoute.params && !!topmostBottomTabRoute.params?.policyID;
+
+ if (!shouldAddPolicyIDToUrl) {
+ return undefined;
+ }
+
+ return topmostBottomTabRoute.params?.policyID as string;
+};
+
+export default getPolicyIDFromState;
diff --git a/src/libs/Navigation/getStateFromPath.ts b/src/libs/Navigation/getStateFromPath.ts
index 0476ffac1494..50254bb3898d 100644
--- a/src/libs/Navigation/getStateFromPath.ts
+++ b/src/libs/Navigation/getStateFromPath.ts
@@ -10,7 +10,8 @@ import linkingConfig from './linkingConfig';
function getStateFromPath(path: Route): PartialState {
const normalizedPath = !path.startsWith('/') ? `/${path}` : path;
- const state = linkingConfig.getStateFromPath ? linkingConfig.getStateFromPath(normalizedPath, linkingConfig.config) : RNGetStateFromPath(normalizedPath, linkingConfig.config);
+ // This function is used in the linkTo function where we want to use default getStateFromPath function.
+ const state = RNGetStateFromPath(normalizedPath, linkingConfig.config);
if (!state) {
throw new Error('Failed to parse the path to a navigation state.');
diff --git a/src/libs/Navigation/getTopmostBottomTabRoute.ts b/src/libs/Navigation/getTopmostBottomTabRoute.ts
new file mode 100644
index 000000000000..8cfc60d99c4a
--- /dev/null
+++ b/src/libs/Navigation/getTopmostBottomTabRoute.ts
@@ -0,0 +1,20 @@
+import type {BottomTabName, NavigationPartialRoute, RootStackParamList, State} from './types';
+
+function getTopmostBottomTabRoute(state: State): NavigationPartialRoute | undefined {
+ const bottomTabNavigatorRoute = state.routes[0];
+
+ // The bottomTabNavigatorRoute state may be empty if we just logged in.
+ if (!bottomTabNavigatorRoute || bottomTabNavigatorRoute.name !== 'BottomTabNavigator' || bottomTabNavigatorRoute.state === undefined) {
+ return undefined;
+ }
+
+ const topmostBottomTabRoute = bottomTabNavigatorRoute.state.routes.at(-1);
+
+ if (!topmostBottomTabRoute) {
+ throw new Error('BottomTabNavigator route have no routes.');
+ }
+
+ return {name: topmostBottomTabRoute.name as BottomTabName, params: topmostBottomTabRoute.params};
+}
+
+export default getTopmostBottomTabRoute;
diff --git a/src/libs/Navigation/getTopmostCentralPaneRoute.ts b/src/libs/Navigation/getTopmostCentralPaneRoute.ts
new file mode 100644
index 000000000000..934cca1a673e
--- /dev/null
+++ b/src/libs/Navigation/getTopmostCentralPaneRoute.ts
@@ -0,0 +1,30 @@
+import NAVIGATORS from '@src/NAVIGATORS';
+import type {CentralPaneName, NavigationPartialRoute, RootStackParamList, State} from './types';
+
+// Get the name of topmost central pane route in the navigation stack.
+function getTopmostCentralPaneRoute(state: State): NavigationPartialRoute | undefined {
+ if (!state) {
+ return;
+ }
+
+ const topmostCentralPane = state.routes.filter((route) => route.name === NAVIGATORS.CENTRAL_PANE_NAVIGATOR).at(-1);
+
+ if (!topmostCentralPane) {
+ return;
+ }
+
+ if (!!topmostCentralPane.params && 'screen' in topmostCentralPane.params) {
+ return {name: topmostCentralPane.params.screen as CentralPaneName, params: topmostCentralPane.params.params};
+ }
+
+ if (!topmostCentralPane.state) {
+ return;
+ }
+
+ // There will be at least one route in the central pane navigator.
+ const {name, params} = topmostCentralPane.state.routes.at(-1) as NavigationPartialRoute;
+
+ return {name, params};
+}
+
+export default getTopmostCentralPaneRoute;
diff --git a/src/libs/Navigation/getTopmostNestedRHPRoute.ts b/src/libs/Navigation/getTopmostNestedRHPRoute.ts
new file mode 100644
index 000000000000..4edcb4e7001e
--- /dev/null
+++ b/src/libs/Navigation/getTopmostNestedRHPRoute.ts
@@ -0,0 +1,25 @@
+import NAVIGATORS from '@src/NAVIGATORS';
+import type {NavigationPartialRoute, State} from './types';
+
+/**
+ * @param state - react-navigation state
+ */
+const getTopmostNestedRHPRoute = (state: State): NavigationPartialRoute | undefined => {
+ const topmostRightPane = state.routes.filter((route) => route.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR).at(-1);
+
+ if (topmostRightPane?.state) {
+ return getTopmostNestedRHPRoute(topmostRightPane.state);
+ }
+
+ const topmostRoute = state.routes.at(-1);
+
+ if (topmostRoute?.state) {
+ return getTopmostNestedRHPRoute(topmostRoute.state);
+ }
+
+ if (topmostRoute) {
+ return {name: topmostRoute.name, params: topmostRoute.params};
+ }
+};
+
+export default getTopmostNestedRHPRoute;
diff --git a/src/libs/Navigation/getTopmostReportActionID.ts b/src/libs/Navigation/getTopmostReportActionID.ts
index f9931343b2eb..a1527a5f36b8 100644
--- a/src/libs/Navigation/getTopmostReportActionID.ts
+++ b/src/libs/Navigation/getTopmostReportActionID.ts
@@ -3,7 +3,7 @@ import NAVIGATORS from '@src/NAVIGATORS';
import SCREENS from '@src/SCREENS';
import type {RootStackParamList} from './types';
-// This function is in a separate file than Navigation.js to avoid cyclic dependency.
+// This function is in a separate file than Navigation.ts to avoid cyclic dependency.
/**
* Find the last visited report screen in the navigation state and get the linked reportActionID of it.
diff --git a/src/libs/Navigation/getTopmostReportId.ts b/src/libs/Navigation/getTopmostReportId.ts
index 2c3b74e9f52f..4438cb273b84 100644
--- a/src/libs/Navigation/getTopmostReportId.ts
+++ b/src/libs/Navigation/getTopmostReportId.ts
@@ -3,7 +3,7 @@ import NAVIGATORS from '@src/NAVIGATORS';
import SCREENS from '@src/SCREENS';
import type {RootStackParamList} from './types';
-// This function is in a separate file than Navigation.js to avoid cyclic dependency.
+// This function is in a separate file than Navigation.ts to avoid cyclic dependency.
/**
* Find the last visited report screen in the navigation state and get the id of it.
diff --git a/src/libs/Navigation/getTopmostSettingsCentralPaneName.ts b/src/libs/Navigation/getTopmostSettingsCentralPaneName.ts
new file mode 100644
index 000000000000..0ddea6588ef6
--- /dev/null
+++ b/src/libs/Navigation/getTopmostSettingsCentralPaneName.ts
@@ -0,0 +1,27 @@
+import type {NavigationState, PartialState} from '@react-navigation/native';
+import SCREENS from '@src/SCREENS';
+
+// Get the name of topmost report in the navigation stack.
+function getTopmostSettingsCentralPaneName(state: NavigationState | PartialState): string | undefined {
+ if (!state) {
+ return;
+ }
+
+ const topmostCentralPane = state.routes.filter((route) => typeof route !== 'number' && 'name' in route && route.name === SCREENS.SETTINGS_CENTRAL_PANE).at(-1);
+
+ if (!topmostCentralPane) {
+ return;
+ }
+
+ if (!!topmostCentralPane.params && 'screen' in topmostCentralPane.params && typeof topmostCentralPane.params.screen === 'string') {
+ return topmostCentralPane.params.screen;
+ }
+
+ if (!topmostCentralPane.state) {
+ return;
+ }
+
+ return topmostCentralPane.state?.routes.at(-1)?.name;
+}
+
+export default getTopmostSettingsCentralPaneName;
diff --git a/src/libs/Navigation/linkTo.ts b/src/libs/Navigation/linkTo.ts
index b7746108ac93..49dcee71eda4 100644
--- a/src/libs/Navigation/linkTo.ts
+++ b/src/libs/Navigation/linkTo.ts
@@ -1,14 +1,26 @@
import {getActionFromState} from '@react-navigation/core';
import type {NavigationAction, NavigationContainerRef, NavigationState, PartialState} from '@react-navigation/native';
import type {Writable} from 'type-fest';
+import getIsSmallScreenWidth from '@libs/getIsSmallScreenWidth';
+import {extractPolicyIDFromPath, getPathWithoutPolicyID} from '@libs/PolicyUtils';
import CONST from '@src/CONST';
import NAVIGATORS from '@src/NAVIGATORS';
import type {Route} from '@src/ROUTES';
+import SCREENS from '@src/SCREENS';
+import getActionsFromPartialDiff from './AppNavigator/getActionsFromPartialDiff';
+import getPartialStateDiff from './AppNavigator/getPartialStateDiff';
import dismissModal from './dismissModal';
+import getPolicyIDFromState from './getPolicyIDFromState';
import getStateFromPath from './getStateFromPath';
+import getTopmostBottomTabRoute from './getTopmostBottomTabRoute';
+import getTopmostCentralPaneRoute from './getTopmostCentralPaneRoute';
import getTopmostReportId from './getTopmostReportId';
import linkingConfig from './linkingConfig';
-import type {NavigationRoot, RootStackParamList, StackNavigationAction} from './types';
+import getAdaptedStateFromPath from './linkingConfig/getAdaptedStateFromPath';
+import getMatchingBottomTabRouteForState from './linkingConfig/getMatchingBottomTabRouteForState';
+import getMatchingCentralPaneRouteForState from './linkingConfig/getMatchingCentralPaneRouteForState';
+import replacePathInNestedState from './linkingConfig/replacePathInNestedState';
+import type {NavigationRoot, RootStackParamList, StackNavigationAction, State} from './types';
type ActionPayloadParams = {
screen?: string;
@@ -29,7 +41,7 @@ type ActionPayload = {
*/
function getMinimalAction(action: NavigationAction, state: NavigationState): Writable {
let currentAction: NavigationAction = action;
- let currentState: NavigationState | PartialState | undefined = state;
+ let currentState: State | undefined = state;
let currentTargetKey: string | undefined;
while (currentAction.payload && 'name' in currentAction.payload && currentState?.routes[currentState.index ?? -1].name === currentAction.payload.name) {
@@ -56,6 +68,49 @@ function getMinimalAction(action: NavigationAction, state: NavigationState): Wri
return currentAction;
}
+// Because we need to change the type to push, we also need to set target for this action to the bottom tab navigator.
+function getActionForBottomTabNavigator(
+ action: StackNavigationAction,
+ state: NavigationState,
+ policyID?: string,
+ shouldNavigate?: boolean,
+): Writable | undefined {
+ const bottomTabNavigatorRoute = state.routes.at(0);
+
+ if (!bottomTabNavigatorRoute || bottomTabNavigatorRoute.state === undefined || !action || action.type !== CONST.NAVIGATION.ACTION_TYPE.NAVIGATE) {
+ return;
+ }
+
+ const params = action.payload.params as ActionPayloadParams;
+ let payloadParams = params.params as Record;
+ const screen = params.screen;
+
+ if (!payloadParams) {
+ payloadParams = {policyID};
+ } else if (!('policyID' in payloadParams && !!payloadParams?.policyID)) {
+ payloadParams = {...payloadParams, policyID};
+ }
+
+ // Check if the current bottom tab is the same as the one we want to navigate to. If it is, we don't need to do anything.
+ const bottomTabCurrentTab = getTopmostBottomTabRoute(state);
+ const bottomTabParams = bottomTabCurrentTab?.params as Record;
+
+ // Verify if the policyID is different than the one we are currently on. If it is, we need to navigate to the new policyID.
+ const isNewPolicy = bottomTabParams?.policyID !== payloadParams?.policyID;
+ if (bottomTabCurrentTab?.name === screen && !shouldNavigate && !isNewPolicy) {
+ return;
+ }
+
+ return {
+ type: CONST.NAVIGATION.ACTION_TYPE.PUSH,
+ payload: {
+ name: screen,
+ params: payloadParams,
+ },
+ target: bottomTabNavigatorRoute.state.key,
+ };
+}
+
function isModalNavigator(targetNavigator?: string) {
return targetNavigator === NAVIGATORS.LEFT_MODAL_NAVIGATOR || targetNavigator === NAVIGATORS.RIGHT_MODAL_NAVIGATOR;
}
@@ -73,13 +128,29 @@ export default function linkTo(navigation: NavigationContainerRef;
+ const stateFromPath = getStateFromPath(pathWithoutPolicyID) as PartialState>;
+
+ // Creating path with /w/ included if necessary.
+ const extractedPolicyID = extractPolicyIDFromPath(`/${path}`);
+ const policyIDFromState = getPolicyIDFromState(rootState);
+ const policyID = extractedPolicyID ?? policyIDFromState;
- const rootState = root.getState();
- const state = getStateFromPath(path);
- const action: StackNavigationAction = getActionFromState(state, linkingConfig.config);
+ const isWorkspaceSettingsOpened = getTopmostBottomTabRoute(rootState as State)?.name === SCREENS.WORKSPACE.INITIAL && path.includes('workspace');
+
+ if (policyID && !isWorkspaceSettingsOpened) {
+ // The stateFromPath doesn't include proper path if there is a policy passed with /w/id.
+ // We need to replace the path in the state with the proper one.
+ // To avoid this hacky solution we may want to create custom getActionFromState function in the future.
+ replacePathInNestedState(stateFromPath, `/w/${policyID}${pathWithoutPolicyID}`);
+ }
+
+ const action: StackNavigationAction = getActionFromState(stateFromPath, linkingConfig.config);
// If action type is different than NAVIGATE we can't change it to the PUSH safely
if (action?.type === CONST.NAVIGATION.ACTION_TYPE.NAVIGATE) {
+ const topmostCentralPaneRoute = getTopmostCentralPaneRoute(rootState);
const topRouteName = rootState?.routes?.at(-1)?.name;
const isTargetNavigatorOnTop = topRouteName === action.payload.name;
@@ -88,7 +159,23 @@ export default function linkTo(navigation: NavigationContainerRef)?.policyID !== (matchingBottomTabRoute?.params as Record)?.policyID;
+ if (topmostBottomTabRoute && (topmostBottomTabRoute.name !== matchingBottomTabRoute.name || isNewPolicyID)) {
+ root.dispatch({
+ type: CONST.NAVIGATION.ACTION_TYPE.PUSH,
+ payload: matchingBottomTabRoute,
+ });
+ }
+
action.type = CONST.NAVIGATION.ACTION_TYPE.PUSH;
// If the type is UP, we deeplinked into one of the RHP flows and we want to replace the current screen with the previous one in the flow
@@ -99,9 +186,56 @@ export default function linkTo(navigation: NavigationContainerRef, metainfo);
+ const diffActions = getActionsFromPartialDiff(diff);
+ for (const diffAction of diffActions) {
+ root.dispatch(diffAction);
+ }
+ }
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ } else if (action.payload.name === NAVIGATORS.BOTTOM_TAB_NAVIGATOR) {
+ // If path contains a policyID, we should invoke the navigate function
+ const shouldNavigate = !!extractedPolicyID;
+ const actionForBottomTabNavigator = getActionForBottomTabNavigator(action, rootState, policyID, shouldNavigate);
+
+ if (!actionForBottomTabNavigator) {
+ return;
+ }
+
+ root.dispatch(actionForBottomTabNavigator);
+
+ // If the layout is wide we need to push matching central pane route to the stack.
+ if (!getIsSmallScreenWidth()) {
+ // stateFromPath should always include bottom tab navigator state, so getMatchingCentralPaneRouteForState will be always defined.
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ const matchingCentralPaneRoute = getMatchingCentralPaneRouteForState(stateFromPath)!;
+ if (matchingCentralPaneRoute && 'name' in matchingCentralPaneRoute) {
+ root.dispatch({
+ type: CONST.NAVIGATION.ACTION_TYPE.PUSH,
+ payload: {
+ name: NAVIGATORS.CENTRAL_PANE_NAVIGATOR,
+ params: {
+ screen: matchingCentralPaneRoute.name,
+ params: matchingCentralPaneRoute.params,
+ },
+ },
+ });
+ }
+ } else {
+ // If the layout is small we need to pop everything from the central pane so the bottom tab navigator is visible.
+ root.dispatch({
+ type: 'POP_TO_TOP',
+ target: rootState.key,
+ });
+ }
+ return;
}
}
@@ -122,6 +256,6 @@ export default function linkTo(navigation: NavigationContainerRef = {
- prefixes: [
- 'app://-/',
- 'new-expensify://',
- 'https://www.expensify.cash',
- 'https://staging.expensify.cash',
- 'https://dev.new.expensify.com',
- CONST.NEW_EXPENSIFY_URL,
- CONST.STAGING_NEW_EXPENSIFY_URL,
- ],
- config: {
- initialRouteName: SCREENS.HOME,
- screens: {
- // Main Routes
- [SCREENS.VALIDATE_LOGIN]: ROUTES.VALIDATE_LOGIN,
- [SCREENS.UNLINK_LOGIN]: ROUTES.UNLINK_LOGIN,
- [SCREENS.TRANSITION_BETWEEN_APPS]: ROUTES.TRANSITION_BETWEEN_APPS,
- [SCREENS.CONCIERGE]: ROUTES.CONCIERGE,
- [SCREENS.SIGN_IN_WITH_APPLE_DESKTOP]: ROUTES.APPLE_SIGN_IN,
- [SCREENS.SIGN_IN_WITH_GOOGLE_DESKTOP]: ROUTES.GOOGLE_SIGN_IN,
- [SCREENS.SAML_SIGN_IN]: ROUTES.SAML_SIGN_IN,
- [SCREENS.DESKTOP_SIGN_IN_REDIRECT]: ROUTES.DESKTOP_SIGN_IN_REDIRECT,
- [SCREENS.REPORT_ATTACHMENTS]: ROUTES.REPORT_ATTACHMENTS.route,
- [SCREENS.PROFILE_AVATAR]: ROUTES.PROFILE_AVATAR.route,
- [SCREENS.WORKSPACE_AVATAR]: ROUTES.WORKSPACE_AVATAR.route,
- [SCREENS.REPORT_AVATAR]: ROUTES.REPORT_AVATAR.route,
-
- // Sidebar
- [SCREENS.HOME]: {
- path: ROUTES.HOME,
- },
-
- [NAVIGATORS.CENTRAL_PANE_NAVIGATOR]: {
- screens: {
- [SCREENS.REPORT]: ROUTES.REPORT_WITH_ID.route,
- },
- },
- [SCREENS.NOT_FOUND]: '*',
- [NAVIGATORS.LEFT_MODAL_NAVIGATOR]: {
- screens: {
- [SCREENS.LEFT_MODAL.SEARCH]: {
- screens: {
- [SCREENS.SEARCH_ROOT]: ROUTES.SEARCH,
- },
- },
- },
- },
- [NAVIGATORS.RIGHT_MODAL_NAVIGATOR]: {
- screens: {
- [SCREENS.RIGHT_MODAL.SETTINGS]: {
- screens: {
- [SCREENS.SETTINGS.ROOT]: {
- path: ROUTES.SETTINGS,
- },
- [SCREENS.SETTINGS.WORKSPACES]: {
- path: ROUTES.SETTINGS_WORKSPACES,
- exact: true,
- },
- [SCREENS.SETTINGS.PREFERENCES.ROOT]: {
- path: ROUTES.SETTINGS_PREFERENCES,
- exact: true,
- },
- [SCREENS.SETTINGS.PREFERENCES.PRIORITY_MODE]: {
- path: ROUTES.SETTINGS_PRIORITY_MODE,
- exact: true,
- },
- [SCREENS.SETTINGS.PREFERENCES.LANGUAGE]: {
- path: ROUTES.SETTINGS_LANGUAGE,
- exact: true,
- },
- [SCREENS.SETTINGS.PREFERENCES.THEME]: {
- path: ROUTES.SETTINGS_THEME,
- exact: true,
- },
- [SCREENS.SETTINGS.CLOSE]: {
- path: ROUTES.SETTINGS_CLOSE,
- exact: true,
- },
- [SCREENS.SETTINGS.SECURITY]: {
- path: ROUTES.SETTINGS_SECURITY,
- exact: true,
- },
- [SCREENS.SETTINGS.WALLET.ROOT]: {
- path: ROUTES.SETTINGS_WALLET,
- exact: true,
- },
- [SCREENS.SETTINGS.WALLET.DOMAIN_CARD]: {
- path: ROUTES.SETTINGS_WALLET_DOMAINCARD.route,
- exact: true,
- },
- [SCREENS.SETTINGS.WALLET.REPORT_VIRTUAL_CARD_FRAUD]: {
- path: ROUTES.SETTINGS_REPORT_FRAUD.route,
- exact: true,
- },
- [SCREENS.SETTINGS.WALLET.CARD_GET_PHYSICAL.NAME]: {
- path: ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_NAME.route,
- exact: true,
- },
- [SCREENS.SETTINGS.WALLET.CARD_GET_PHYSICAL.PHONE]: {
- path: ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_PHONE.route,
- exact: true,
- },
- [SCREENS.SETTINGS.WALLET.CARD_GET_PHYSICAL.ADDRESS]: {
- path: ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_ADDRESS.route,
- exact: true,
- },
- [SCREENS.SETTINGS.WALLET.CARD_GET_PHYSICAL.CONFIRM]: {
- path: ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_CONFIRM.route,
- exact: true,
- },
- [SCREENS.SETTINGS.WALLET.ENABLE_PAYMENTS]: {
- path: ROUTES.SETTINGS_ENABLE_PAYMENTS,
- exact: true,
- },
- [SCREENS.SETTINGS.WALLET.TRANSFER_BALANCE]: {
- path: ROUTES.SETTINGS_WALLET_TRANSFER_BALANCE,
- exact: true,
- },
- [SCREENS.SETTINGS.WALLET.CHOOSE_TRANSFER_ACCOUNT]: {
- path: ROUTES.SETTINGS_WALLET_CHOOSE_TRANSFER_ACCOUNT,
- exact: true,
- },
- [SCREENS.SETTINGS.REPORT_CARD_LOST_OR_DAMAGED]: {
- path: ROUTES.SETTINGS_WALLET_REPORT_CARD_LOST_OR_DAMAGED.route,
- exact: true,
- },
- [SCREENS.SETTINGS.WALLET.CARD_ACTIVATE]: {
- path: ROUTES.SETTINGS_WALLET_CARD_ACTIVATE.route,
- exact: true,
- },
- [SCREENS.SETTINGS.WALLET.CARDS_DIGITAL_DETAILS_UPDATE_ADDRESS]: {
- path: ROUTES.SETTINGS_WALLET_CARD_DIGITAL_DETAILS_UPDATE_ADDRESS.route,
- exact: true,
- },
- [SCREENS.SETTINGS.ADD_DEBIT_CARD]: {
- path: ROUTES.SETTINGS_ADD_DEBIT_CARD,
- exact: true,
- },
- [SCREENS.SETTINGS.ADD_BANK_ACCOUNT]: {
- path: ROUTES.SETTINGS_ADD_BANK_ACCOUNT,
- exact: true,
- },
- [SCREENS.SETTINGS.PROFILE.ROOT]: {
- path: ROUTES.SETTINGS_PROFILE,
- exact: true,
- },
- [SCREENS.SETTINGS.PROFILE.PRONOUNS]: {
- path: ROUTES.SETTINGS_PRONOUNS,
- exact: true,
- },
- [SCREENS.SETTINGS.PROFILE.DISPLAY_NAME]: {
- path: ROUTES.SETTINGS_DISPLAY_NAME,
- exact: true,
- },
- [SCREENS.SETTINGS.PROFILE.TIMEZONE]: {
- path: ROUTES.SETTINGS_TIMEZONE,
- exact: true,
- },
- [SCREENS.SETTINGS.PROFILE.TIMEZONE_SELECT]: {
- path: ROUTES.SETTINGS_TIMEZONE_SELECT,
- exact: true,
- },
- [SCREENS.SETTINGS.ABOUT]: {
- path: ROUTES.SETTINGS_ABOUT,
- exact: true,
- },
- [SCREENS.SETTINGS.APP_DOWNLOAD_LINKS]: {
- path: ROUTES.SETTINGS_APP_DOWNLOAD_LINKS,
- exact: true,
- },
- [SCREENS.SETTINGS.PROFILE.CONTACT_METHODS]: {
- path: ROUTES.SETTINGS_CONTACT_METHODS.route,
- exact: true,
- },
- [SCREENS.SETTINGS.PROFILE.CONTACT_METHOD_DETAILS]: {
- path: ROUTES.SETTINGS_CONTACT_METHOD_DETAILS.route,
- },
- [SCREENS.SETTINGS.LOUNGE_ACCESS]: {
- path: ROUTES.SETTINGS_LOUNGE_ACCESS,
- },
- [SCREENS.SETTINGS.PROFILE.NEW_CONTACT_METHOD]: {
- path: ROUTES.SETTINGS_NEW_CONTACT_METHOD.route,
- exact: true,
- },
- [SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.INITIAL]: {
- path: ROUTES.SETTINGS_PERSONAL_DETAILS,
- exact: true,
- },
- [SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.LEGAL_NAME]: {
- path: ROUTES.SETTINGS_PERSONAL_DETAILS_LEGAL_NAME,
- exact: true,
- },
- [SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.DATE_OF_BIRTH]: {
- path: ROUTES.SETTINGS_PERSONAL_DETAILS_DATE_OF_BIRTH,
- exact: true,
- },
- [SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.ADDRESS]: {
- path: ROUTES.SETTINGS_PERSONAL_DETAILS_ADDRESS,
- exact: true,
- },
- [SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.ADDRESS_COUNTRY]: {
- path: ROUTES.SETTINGS_PERSONAL_DETAILS_ADDRESS_COUNTRY.route,
- exact: true,
- },
- [SCREENS.SETTINGS.TWO_FACTOR_AUTH]: {
- path: ROUTES.SETTINGS_2FA.route,
- exact: true,
- },
- [SCREENS.SETTINGS.SHARE_CODE]: {
- path: ROUTES.SETTINGS_SHARE_CODE,
- exact: true,
- },
- [SCREENS.SETTINGS.PROFILE.STATUS]: {
- path: ROUTES.SETTINGS_STATUS,
- exact: true,
- },
- [SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER]: {
- path: ROUTES.SETTINGS_STATUS_CLEAR_AFTER,
- },
- [SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_DATE]: {
- path: ROUTES.SETTINGS_STATUS_CLEAR_AFTER_DATE,
- },
- [SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_TIME]: {
- path: ROUTES.SETTINGS_STATUS_CLEAR_AFTER_TIME,
- },
- [SCREENS.WORKSPACE.INITIAL]: {
- path: ROUTES.WORKSPACE_INITIAL.route,
- },
- [SCREENS.WORKSPACE.SETTINGS]: {
- path: ROUTES.WORKSPACE_SETTINGS.route,
- },
- [SCREENS.WORKSPACE.CURRENCY]: {
- path: ROUTES.WORKSPACE_SETTINGS_CURRENCY.route,
- },
- [SCREENS.WORKSPACE.CARD]: {
- path: ROUTES.WORKSPACE_CARD.route,
- },
- [SCREENS.WORKSPACE.REIMBURSE]: {
- path: ROUTES.WORKSPACE_REIMBURSE.route,
- },
- [SCREENS.WORKSPACE.RATE_AND_UNIT]: {
- path: ROUTES.WORKSPACE_RATE_AND_UNIT.route,
- },
- [SCREENS.WORKSPACE.BILLS]: {
- path: ROUTES.WORKSPACE_BILLS.route,
- },
- [SCREENS.WORKSPACE.INVOICES]: {
- path: ROUTES.WORKSPACE_INVOICES.route,
- },
- [SCREENS.WORKSPACE.TRAVEL]: {
- path: ROUTES.WORKSPACE_TRAVEL.route,
- },
- [SCREENS.WORKSPACE.MEMBERS]: {
- path: ROUTES.WORKSPACE_MEMBERS.route,
- },
- [SCREENS.WORKSPACE.INVITE]: {
- path: ROUTES.WORKSPACE_INVITE.route,
- },
- [SCREENS.WORKSPACE.INVITE_MESSAGE]: {
- path: ROUTES.WORKSPACE_INVITE_MESSAGE.route,
- },
- [SCREENS.REIMBURSEMENT_ACCOUNT]: {
- path: ROUTES.BANK_ACCOUNT_WITH_STEP_TO_OPEN.route,
- exact: true,
- },
- [SCREENS.GET_ASSISTANCE]: {
- path: ROUTES.GET_ASSISTANCE.route,
- },
- [SCREENS.KEYBOARD_SHORTCUTS]: {
- path: ROUTES.KEYBOARD_SHORTCUTS,
- },
- },
- },
- [SCREENS.RIGHT_MODAL.PRIVATE_NOTES]: {
- screens: {
- [SCREENS.PRIVATE_NOTES.LIST]: ROUTES.PRIVATE_NOTES_LIST.route,
- [SCREENS.PRIVATE_NOTES.EDIT]: ROUTES.PRIVATE_NOTES_EDIT.route,
- },
- },
- [SCREENS.RIGHT_MODAL.REPORT_DETAILS]: {
- screens: {
- [SCREENS.REPORT_DETAILS.ROOT]: ROUTES.REPORT_WITH_ID_DETAILS.route,
- [SCREENS.REPORT_DETAILS.SHARE_CODE]: ROUTES.REPORT_WITH_ID_DETAILS_SHARE_CODE.route,
- },
- },
- [SCREENS.RIGHT_MODAL.REPORT_SETTINGS]: {
- screens: {
- [SCREENS.REPORT_SETTINGS.ROOT]: {
- path: ROUTES.REPORT_SETTINGS.route,
- },
- [SCREENS.REPORT_SETTINGS.ROOM_NAME]: {
- path: ROUTES.REPORT_SETTINGS_ROOM_NAME.route,
- },
- [SCREENS.REPORT_SETTINGS.NOTIFICATION_PREFERENCES]: {
- path: ROUTES.REPORT_SETTINGS_NOTIFICATION_PREFERENCES.route,
- },
- [SCREENS.REPORT_SETTINGS.WRITE_CAPABILITY]: {
- path: ROUTES.REPORT_SETTINGS_WRITE_CAPABILITY.route,
- },
- },
- },
- [SCREENS.RIGHT_MODAL.REPORT_WELCOME_MESSAGE]: {
- screens: {
- [SCREENS.REPORT_WELCOME_MESSAGE_ROOT]: ROUTES.REPORT_WELCOME_MESSAGE.route,
- },
- },
- [SCREENS.RIGHT_MODAL.NEW_CHAT]: {
- screens: {
- [SCREENS.NEW_CHAT.ROOT]: {
- path: ROUTES.NEW,
- exact: true,
- screens: {
- [SCREENS.NEW_CHAT.NEW_CHAT]: {
- path: ROUTES.NEW_CHAT,
- exact: true,
- },
- [SCREENS.NEW_CHAT.NEW_ROOM]: {
- path: ROUTES.NEW_ROOM,
- exact: true,
- },
- },
- },
- },
- },
- [SCREENS.RIGHT_MODAL.NEW_TASK]: {
- screens: {
- [SCREENS.NEW_TASK.ROOT]: ROUTES.NEW_TASK,
- [SCREENS.NEW_TASK.TASK_ASSIGNEE_SELECTOR]: ROUTES.NEW_TASK_ASSIGNEE,
- [SCREENS.NEW_TASK.TASK_SHARE_DESTINATION_SELECTOR]: ROUTES.NEW_TASK_SHARE_DESTINATION,
- [SCREENS.NEW_TASK.DETAILS]: ROUTES.NEW_TASK_DETAILS,
- [SCREENS.NEW_TASK.TITLE]: ROUTES.NEW_TASK_TITLE,
- [SCREENS.NEW_TASK.DESCRIPTION]: ROUTES.NEW_TASK_DESCRIPTION,
- },
- },
- [SCREENS.RIGHT_MODAL.TEACHERS_UNITE]: {
- screens: {
- [SCREENS.SAVE_THE_WORLD.ROOT]: ROUTES.TEACHERS_UNITE,
- [SCREENS.I_KNOW_A_TEACHER]: ROUTES.I_KNOW_A_TEACHER,
- [SCREENS.INTRO_SCHOOL_PRINCIPAL]: ROUTES.INTRO_SCHOOL_PRINCIPAL,
- [SCREENS.I_AM_A_TEACHER]: ROUTES.I_AM_A_TEACHER,
- },
- },
- [SCREENS.RIGHT_MODAL.DETAILS]: {
- screens: {
- [SCREENS.DETAILS_ROOT]: ROUTES.DETAILS.route,
- },
- },
- [SCREENS.RIGHT_MODAL.PROFILE]: {
- screens: {
- [SCREENS.PROFILE_ROOT]: ROUTES.PROFILE.route,
- },
- },
- [SCREENS.RIGHT_MODAL.PARTICIPANTS]: {
- screens: {
- [SCREENS.REPORT_PARTICIPANTS_ROOT]: ROUTES.REPORT_PARTICIPANTS.route,
- },
- },
- [SCREENS.RIGHT_MODAL.ROOM_INVITE]: {
- screens: {
- [SCREENS.ROOM_INVITE_ROOT]: ROUTES.ROOM_INVITE.route,
- },
- },
- [SCREENS.RIGHT_MODAL.ROOM_MEMBERS]: {
- screens: {
- [SCREENS.ROOM_MEMBERS_ROOT]: ROUTES.ROOM_MEMBERS.route,
- },
- },
- [SCREENS.RIGHT_MODAL.MONEY_REQUEST]: {
- screens: {
- [SCREENS.MONEY_REQUEST.START]: ROUTES.MONEY_REQUEST_START.route,
- [SCREENS.MONEY_REQUEST.CREATE]: {
- path: ROUTES.MONEY_REQUEST_CREATE.route,
- exact: true,
- screens: {
- distance: {
- path: ROUTES.MONEY_REQUEST_CREATE_TAB_DISTANCE.route,
- exact: true,
- },
- manual: {
- path: ROUTES.MONEY_REQUEST_CREATE_TAB_MANUAL.route,
- exact: true,
- },
- scan: {
- path: ROUTES.MONEY_REQUEST_CREATE_TAB_SCAN.route,
- exact: true,
- },
- },
- },
- [SCREENS.MONEY_REQUEST.STEP_AMOUNT]: ROUTES.MONEY_REQUEST_STEP_AMOUNT.route,
- [SCREENS.MONEY_REQUEST.STEP_CATEGORY]: ROUTES.MONEY_REQUEST_STEP_CATEGORY.route,
- [SCREENS.MONEY_REQUEST.STEP_CONFIRMATION]: ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.route,
- [SCREENS.MONEY_REQUEST.STEP_CURRENCY]: ROUTES.MONEY_REQUEST_STEP_CURRENCY.route,
- [SCREENS.MONEY_REQUEST.STEP_DATE]: ROUTES.MONEY_REQUEST_STEP_DATE.route,
- [SCREENS.MONEY_REQUEST.STEP_DESCRIPTION]: ROUTES.MONEY_REQUEST_STEP_DESCRIPTION.route,
- [SCREENS.MONEY_REQUEST.STEP_DISTANCE]: ROUTES.MONEY_REQUEST_STEP_DISTANCE.route,
- [SCREENS.MONEY_REQUEST.STEP_MERCHANT]: ROUTES.MONEY_REQUEST_STEP_MERCHANT.route,
- [SCREENS.MONEY_REQUEST.STEP_PARTICIPANTS]: ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.route,
- [SCREENS.MONEY_REQUEST.STEP_SCAN]: ROUTES.MONEY_REQUEST_STEP_SCAN.route,
- [SCREENS.MONEY_REQUEST.STEP_TAG]: ROUTES.MONEY_REQUEST_STEP_TAG.route,
- [SCREENS.MONEY_REQUEST.STEP_WAYPOINT]: ROUTES.MONEY_REQUEST_STEP_WAYPOINT.route,
- [SCREENS.MONEY_REQUEST.ROOT]: {
- path: ROUTES.MONEY_REQUEST.route,
- exact: true,
- screens: {
- [SCREENS.MONEY_REQUEST.MANUAL_TAB]: {
- path: ROUTES.MONEY_REQUEST_MANUAL_TAB,
- exact: true,
- },
- [SCREENS.MONEY_REQUEST.SCAN_TAB]: {
- path: ROUTES.MONEY_REQUEST_SCAN_TAB,
- exact: true,
- },
- [SCREENS.MONEY_REQUEST.DISTANCE_TAB]: {
- path: ROUTES.MONEY_REQUEST_DISTANCE_TAB.route,
- exact: true,
- },
- },
- },
- [SCREENS.MONEY_REQUEST.AMOUNT]: ROUTES.MONEY_REQUEST_AMOUNT.route,
- [SCREENS.MONEY_REQUEST.STEP_TAX_AMOUNT]: ROUTES.MONEY_REQUEST_STEP_TAX_AMOUNT.route,
- [SCREENS.MONEY_REQUEST.STEP_TAX_RATE]: ROUTES.MONEY_REQUEST_STEP_TAX_RATE.route,
- [SCREENS.MONEY_REQUEST.PARTICIPANTS]: ROUTES.MONEY_REQUEST_PARTICIPANTS.route,
- [SCREENS.MONEY_REQUEST.CONFIRMATION]: ROUTES.MONEY_REQUEST_CONFIRMATION.route,
- [SCREENS.MONEY_REQUEST.DATE]: ROUTES.MONEY_REQUEST_DATE.route,
- [SCREENS.MONEY_REQUEST.CURRENCY]: ROUTES.MONEY_REQUEST_CURRENCY.route,
- [SCREENS.MONEY_REQUEST.DESCRIPTION]: ROUTES.MONEY_REQUEST_DESCRIPTION.route,
- [SCREENS.MONEY_REQUEST.CATEGORY]: ROUTES.MONEY_REQUEST_CATEGORY.route,
- [SCREENS.MONEY_REQUEST.TAG]: ROUTES.MONEY_REQUEST_TAG.route,
- [SCREENS.MONEY_REQUEST.MERCHANT]: ROUTES.MONEY_REQUEST_MERCHANT.route,
- [SCREENS.MONEY_REQUEST.RECEIPT]: ROUTES.MONEY_REQUEST_RECEIPT.route,
- [SCREENS.MONEY_REQUEST.DISTANCE]: ROUTES.MONEY_REQUEST_DISTANCE.route,
- [SCREENS.IOU_SEND.ENABLE_PAYMENTS]: ROUTES.IOU_SEND_ENABLE_PAYMENTS,
- [SCREENS.IOU_SEND.ADD_BANK_ACCOUNT]: ROUTES.IOU_SEND_ADD_BANK_ACCOUNT,
- [SCREENS.IOU_SEND.ADD_DEBIT_CARD]: ROUTES.IOU_SEND_ADD_DEBIT_CARD,
- },
- },
- [SCREENS.RIGHT_MODAL.SPLIT_DETAILS]: {
- screens: {
- [SCREENS.SPLIT_DETAILS.ROOT]: ROUTES.SPLIT_BILL_DETAILS.route,
- [SCREENS.SPLIT_DETAILS.EDIT_REQUEST]: ROUTES.EDIT_SPLIT_BILL.route,
- [SCREENS.SPLIT_DETAILS.EDIT_CURRENCY]: ROUTES.EDIT_SPLIT_BILL_CURRENCY.route,
- },
- },
- [SCREENS.RIGHT_MODAL.TASK_DETAILS]: {
- screens: {
- [SCREENS.TASK.TITLE]: ROUTES.TASK_TITLE.route,
- [SCREENS.TASK.DESCRIPTION]: ROUTES.TASK_DESCRIPTION.route,
- [SCREENS.TASK.ASSIGNEE]: ROUTES.TASK_ASSIGNEE.route,
- },
- },
- [SCREENS.RIGHT_MODAL.ADD_PERSONAL_BANK_ACCOUNT]: {
- screens: {
- [SCREENS.ADD_PERSONAL_BANK_ACCOUNT_ROOT]: ROUTES.BANK_ACCOUNT_PERSONAL,
- },
- },
- [SCREENS.RIGHT_MODAL.ENABLE_PAYMENTS]: {
- screens: {
- [SCREENS.ENABLE_PAYMENTS_ROOT]: ROUTES.ENABLE_PAYMENTS,
- },
- },
- [SCREENS.RIGHT_MODAL.WALLET_STATEMENT]: {
- screens: {
- [SCREENS.WALLET_STATEMENT_ROOT]: ROUTES.WALLET_STATEMENT_WITH_DATE,
- },
- },
- [SCREENS.RIGHT_MODAL.FLAG_COMMENT]: {
- screens: {
- [SCREENS.FLAG_COMMENT_ROOT]: ROUTES.FLAG_COMMENT.route,
- },
- },
- [SCREENS.RIGHT_MODAL.EDIT_REQUEST]: {
- screens: {
- [SCREENS.EDIT_REQUEST.ROOT]: ROUTES.EDIT_REQUEST.route,
- [SCREENS.EDIT_REQUEST.CURRENCY]: ROUTES.EDIT_CURRENCY_REQUEST.route,
- [SCREENS.EDIT_REQUEST.REPORT_FIELD]: ROUTES.EDIT_REPORT_FIELD_REQUEST.route,
- },
- },
- [SCREENS.RIGHT_MODAL.SIGN_IN]: {
- screens: {
- [SCREENS.SIGN_IN_ROOT]: ROUTES.SIGN_IN_MODAL,
- },
- },
- [SCREENS.RIGHT_MODAL.REFERRAL]: {
- screens: {
- [SCREENS.REFERRAL_DETAILS]: ROUTES.REFERRAL_DETAILS_MODAL.route,
- },
- },
- ProcessMoneyRequestHold: {
- screens: {
- ProcessMoneyRequestHold_Root: ROUTES.PROCESS_MONEY_REQUEST_HOLD,
- },
- },
- },
- },
- },
- },
-};
-
-export default linkingConfig;
diff --git a/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts b/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts
new file mode 100755
index 000000000000..d61b36871434
--- /dev/null
+++ b/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts
@@ -0,0 +1,10 @@
+import type {CentralPaneName} from '@libs/Navigation/types';
+import SCREENS from '@src/SCREENS';
+
+const CENTRAL_PANE_TO_RHP_MAPPING: Partial> = {
+ [SCREENS.WORKSPACE.OVERVIEW]: [SCREENS.WORKSPACE.NAME, SCREENS.WORKSPACE.CURRENCY],
+ [SCREENS.WORKSPACE.REIMBURSE]: [SCREENS.WORKSPACE.RATE_AND_UNIT],
+ [SCREENS.WORKSPACE.MEMBERS]: [SCREENS.WORKSPACE.INVITE, SCREENS.WORKSPACE.INVITE_MESSAGE],
+};
+
+export default CENTRAL_PANE_TO_RHP_MAPPING;
diff --git a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts
new file mode 100755
index 000000000000..64174c7ab724
--- /dev/null
+++ b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts
@@ -0,0 +1,41 @@
+import type {FullScreenName} from '@libs/Navigation/types';
+import SCREENS from '@src/SCREENS';
+
+const FULL_SCREEN_TO_RHP_MAPPING: Partial> = {
+ [SCREENS.SETTINGS.PROFILE.ROOT]: [
+ SCREENS.SETTINGS.PROFILE.DISPLAY_NAME,
+ SCREENS.SETTINGS.PROFILE.CONTACT_METHODS,
+ SCREENS.SETTINGS.PROFILE.CONTACT_METHOD_DETAILS,
+ SCREENS.SETTINGS.PROFILE.NEW_CONTACT_METHOD,
+ SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER,
+ SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_DATE,
+ SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_TIME,
+ SCREENS.SETTINGS.PROFILE.STATUS,
+ SCREENS.SETTINGS.PROFILE.PRONOUNS,
+ SCREENS.SETTINGS.PROFILE.TIMEZONE,
+ SCREENS.SETTINGS.PROFILE.TIMEZONE_SELECT,
+ SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.INITIAL,
+ SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.LEGAL_NAME,
+ SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.DATE_OF_BIRTH,
+ SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.ADDRESS,
+ SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.ADDRESS_COUNTRY,
+ ],
+ [SCREENS.SETTINGS.PREFERENCES.ROOT]: [SCREENS.SETTINGS.PREFERENCES.PRIORITY_MODE, SCREENS.SETTINGS.PREFERENCES.LANGUAGE, SCREENS.SETTINGS.PREFERENCES.THEME],
+ [SCREENS.SETTINGS.WALLET.ROOT]: [
+ SCREENS.SETTINGS.WALLET.DOMAIN_CARD,
+ SCREENS.SETTINGS.WALLET.CARD_GET_PHYSICAL.NAME,
+ SCREENS.SETTINGS.WALLET.CARD_GET_PHYSICAL.PHONE,
+ SCREENS.SETTINGS.WALLET.CARD_GET_PHYSICAL.ADDRESS,
+ SCREENS.SETTINGS.WALLET.CARD_GET_PHYSICAL.CONFIRM,
+ SCREENS.SETTINGS.WALLET.TRANSFER_BALANCE,
+ SCREENS.SETTINGS.WALLET.CHOOSE_TRANSFER_ACCOUNT,
+ SCREENS.SETTINGS.WALLET.ENABLE_PAYMENTS,
+ SCREENS.SETTINGS.WALLET.CARD_ACTIVATE,
+ SCREENS.SETTINGS.WALLET.REPORT_VIRTUAL_CARD_FRAUD,
+ SCREENS.SETTINGS.WALLET.CARDS_DIGITAL_DETAILS_UPDATE_ADDRESS,
+ ],
+ [SCREENS.SETTINGS.SECURITY]: [SCREENS.SETTINGS.TWO_FACTOR_AUTH, SCREENS.SETTINGS.CLOSE],
+ [SCREENS.SETTINGS.ABOUT]: [SCREENS.SETTINGS.APP_DOWNLOAD_LINKS, SCREENS.KEYBOARD_SHORTCUTS],
+};
+
+export default FULL_SCREEN_TO_RHP_MAPPING;
diff --git a/src/libs/Navigation/linkingConfig/TAB_TO_CENTRAL_PANE_MAPPING.ts b/src/libs/Navigation/linkingConfig/TAB_TO_CENTRAL_PANE_MAPPING.ts
new file mode 100755
index 000000000000..3344cffe94ae
--- /dev/null
+++ b/src/libs/Navigation/linkingConfig/TAB_TO_CENTRAL_PANE_MAPPING.ts
@@ -0,0 +1,31 @@
+import type {BottomTabName, CentralPaneName} from '@navigation/types';
+import SCREENS from '@src/SCREENS';
+
+const TAB_TO_CENTRAL_PANE_MAPPING: Record = {
+ [SCREENS.HOME]: [SCREENS.REPORT],
+ [SCREENS.ALL_SETTINGS]: [SCREENS.SETTINGS.WORKSPACES],
+ [SCREENS.WORKSPACE.INITIAL]: [
+ SCREENS.WORKSPACE.OVERVIEW,
+ SCREENS.WORKSPACE.CARD,
+ SCREENS.WORKSPACE.REIMBURSE,
+ SCREENS.WORKSPACE.BILLS,
+ SCREENS.WORKSPACE.INVOICES,
+ SCREENS.WORKSPACE.TRAVEL,
+ SCREENS.WORKSPACE.MEMBERS,
+ ],
+};
+
+const generateCentralPaneToTabMapping = (): Record => {
+ const mapping: Record = {} as Record;
+ for (const [tabName, centralPaneNames] of Object.entries(TAB_TO_CENTRAL_PANE_MAPPING)) {
+ for (const centralPaneName of centralPaneNames) {
+ mapping[centralPaneName] = tabName as BottomTabName;
+ }
+ }
+ return mapping;
+};
+
+const CENTRAL_PANE_TO_TAB_MAPPING: Record = generateCentralPaneToTabMapping();
+
+export {CENTRAL_PANE_TO_TAB_MAPPING};
+export default TAB_TO_CENTRAL_PANE_MAPPING;
diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts
new file mode 100644
index 000000000000..f1c9c316fe93
--- /dev/null
+++ b/src/libs/Navigation/linkingConfig/config.ts
@@ -0,0 +1,515 @@
+/* eslint-disable @typescript-eslint/naming-convention */
+import type {LinkingOptions} from '@react-navigation/native';
+import type {RootStackParamList} from '@navigation/types';
+import NAVIGATORS from '@src/NAVIGATORS';
+import ROUTES from '@src/ROUTES';
+import SCREENS from '@src/SCREENS';
+
+// Moved to a separate file to avoid cyclic dependencies.
+const config: LinkingOptions['config'] = {
+ initialRouteName: NAVIGATORS.BOTTOM_TAB_NAVIGATOR,
+ screens: {
+ // Main Routes
+ [SCREENS.VALIDATE_LOGIN]: ROUTES.VALIDATE_LOGIN,
+ [SCREENS.UNLINK_LOGIN]: ROUTES.UNLINK_LOGIN,
+ [SCREENS.TRANSITION_BETWEEN_APPS]: ROUTES.TRANSITION_BETWEEN_APPS,
+ [SCREENS.CONCIERGE]: ROUTES.CONCIERGE,
+ [SCREENS.SIGN_IN_WITH_APPLE_DESKTOP]: ROUTES.APPLE_SIGN_IN,
+ [SCREENS.SIGN_IN_WITH_GOOGLE_DESKTOP]: ROUTES.GOOGLE_SIGN_IN,
+ [SCREENS.SAML_SIGN_IN]: ROUTES.SAML_SIGN_IN,
+ [SCREENS.DESKTOP_SIGN_IN_REDIRECT]: ROUTES.DESKTOP_SIGN_IN_REDIRECT,
+ [SCREENS.REPORT_ATTACHMENTS]: ROUTES.REPORT_ATTACHMENTS.route,
+ [SCREENS.PROFILE_AVATAR]: ROUTES.PROFILE_AVATAR.route,
+ [SCREENS.WORKSPACE_AVATAR]: ROUTES.WORKSPACE_AVATAR.route,
+ [SCREENS.REPORT_AVATAR]: ROUTES.REPORT_AVATAR.route,
+
+ // Sidebar
+ [NAVIGATORS.BOTTOM_TAB_NAVIGATOR]: {
+ path: ROUTES.ROOT,
+ initialRouteName: SCREENS.HOME,
+ screens: {
+ [SCREENS.HOME]: ROUTES.HOME,
+ [SCREENS.ALL_SETTINGS]: ROUTES.ALL_SETTINGS,
+ [SCREENS.WORKSPACE.INITIAL]: {
+ path: ROUTES.WORKSPACE_INITIAL.route,
+ exact: true,
+ },
+ },
+ },
+
+ [NAVIGATORS.CENTRAL_PANE_NAVIGATOR]: {
+ screens: {
+ [SCREENS.REPORT]: ROUTES.REPORT_WITH_ID.route,
+
+ [SCREENS.SETTINGS.WORKSPACES]: ROUTES.SETTINGS_WORKSPACES,
+ [SCREENS.WORKSPACE.OVERVIEW]: ROUTES.WORKSPACE_OVERVIEW.route,
+ [SCREENS.WORKSPACE.CARD]: {
+ path: ROUTES.WORKSPACE_CARD.route,
+ },
+ [SCREENS.WORKSPACE.REIMBURSE]: {
+ path: ROUTES.WORKSPACE_REIMBURSE.route,
+ },
+ [SCREENS.WORKSPACE.BILLS]: {
+ path: ROUTES.WORKSPACE_BILLS.route,
+ },
+ [SCREENS.WORKSPACE.INVOICES]: {
+ path: ROUTES.WORKSPACE_INVOICES.route,
+ },
+ [SCREENS.WORKSPACE.TRAVEL]: {
+ path: ROUTES.WORKSPACE_TRAVEL.route,
+ },
+ [SCREENS.WORKSPACE.MEMBERS]: {
+ path: ROUTES.WORKSPACE_MEMBERS.route,
+ },
+ },
+ },
+ [SCREENS.NOT_FOUND]: '*',
+ [NAVIGATORS.LEFT_MODAL_NAVIGATOR]: {
+ screens: {
+ [SCREENS.LEFT_MODAL.SEARCH]: {
+ screens: {
+ [SCREENS.SEARCH_ROOT]: ROUTES.SEARCH,
+ },
+ },
+ [SCREENS.LEFT_MODAL.WORKSPACE_SWITCHER]: {
+ screens: {
+ [SCREENS.WORKSPACE_SWITCHER.ROOT]: {
+ path: ROUTES.WORKSPACE_SWITCHER,
+ },
+ },
+ },
+ },
+ },
+ [NAVIGATORS.RIGHT_MODAL_NAVIGATOR]: {
+ screens: {
+ [SCREENS.RIGHT_MODAL.SETTINGS]: {
+ screens: {
+ [SCREENS.SETTINGS.PREFERENCES.PRIORITY_MODE]: {
+ path: ROUTES.SETTINGS_PRIORITY_MODE,
+ exact: true,
+ },
+ [SCREENS.SETTINGS.PREFERENCES.LANGUAGE]: {
+ path: ROUTES.SETTINGS_LANGUAGE,
+ exact: true,
+ },
+ [SCREENS.SETTINGS.PREFERENCES.THEME]: {
+ path: ROUTES.SETTINGS_THEME,
+ exact: true,
+ },
+ [SCREENS.SETTINGS.CLOSE]: {
+ path: ROUTES.SETTINGS_CLOSE,
+ exact: true,
+ },
+ [SCREENS.SETTINGS.WALLET.DOMAIN_CARD]: {
+ path: ROUTES.SETTINGS_WALLET_DOMAINCARD.route,
+ exact: true,
+ },
+ [SCREENS.SETTINGS.WALLET.REPORT_VIRTUAL_CARD_FRAUD]: {
+ path: ROUTES.SETTINGS_REPORT_FRAUD.route,
+ exact: true,
+ },
+ [SCREENS.SETTINGS.WALLET.CARD_GET_PHYSICAL.NAME]: {
+ path: ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_NAME.route,
+ exact: true,
+ },
+ [SCREENS.SETTINGS.WALLET.CARD_GET_PHYSICAL.PHONE]: {
+ path: ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_PHONE.route,
+ exact: true,
+ },
+ [SCREENS.SETTINGS.WALLET.CARD_GET_PHYSICAL.ADDRESS]: {
+ path: ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_ADDRESS.route,
+ exact: true,
+ },
+ [SCREENS.SETTINGS.WALLET.CARD_GET_PHYSICAL.CONFIRM]: {
+ path: ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_CONFIRM.route,
+ exact: true,
+ },
+ [SCREENS.SETTINGS.WALLET.ENABLE_PAYMENTS]: {
+ path: ROUTES.SETTINGS_ENABLE_PAYMENTS,
+ exact: true,
+ },
+ [SCREENS.SETTINGS.WALLET.TRANSFER_BALANCE]: {
+ path: ROUTES.SETTINGS_WALLET_TRANSFER_BALANCE,
+ exact: true,
+ },
+ [SCREENS.SETTINGS.WALLET.CHOOSE_TRANSFER_ACCOUNT]: {
+ path: ROUTES.SETTINGS_WALLET_CHOOSE_TRANSFER_ACCOUNT,
+ exact: true,
+ },
+ [SCREENS.SETTINGS.REPORT_CARD_LOST_OR_DAMAGED]: {
+ path: ROUTES.SETTINGS_WALLET_REPORT_CARD_LOST_OR_DAMAGED.route,
+ exact: true,
+ },
+ [SCREENS.SETTINGS.WALLET.CARD_ACTIVATE]: {
+ path: ROUTES.SETTINGS_WALLET_CARD_ACTIVATE.route,
+ exact: true,
+ },
+ [SCREENS.SETTINGS.WALLET.CARDS_DIGITAL_DETAILS_UPDATE_ADDRESS]: {
+ path: ROUTES.SETTINGS_WALLET_CARD_DIGITAL_DETAILS_UPDATE_ADDRESS.route,
+ exact: true,
+ },
+ [SCREENS.SETTINGS.ADD_DEBIT_CARD]: {
+ path: ROUTES.SETTINGS_ADD_DEBIT_CARD,
+ exact: true,
+ },
+ [SCREENS.SETTINGS.ADD_BANK_ACCOUNT]: {
+ path: ROUTES.SETTINGS_ADD_BANK_ACCOUNT,
+ exact: true,
+ },
+ [SCREENS.SETTINGS.PROFILE.PRONOUNS]: {
+ path: ROUTES.SETTINGS_PRONOUNS,
+ exact: true,
+ },
+ [SCREENS.SETTINGS.PROFILE.DISPLAY_NAME]: {
+ path: ROUTES.SETTINGS_DISPLAY_NAME,
+ exact: true,
+ },
+ [SCREENS.SETTINGS.PROFILE.TIMEZONE]: {
+ path: ROUTES.SETTINGS_TIMEZONE,
+ exact: true,
+ },
+ [SCREENS.SETTINGS.PROFILE.TIMEZONE_SELECT]: {
+ path: ROUTES.SETTINGS_TIMEZONE_SELECT,
+ exact: true,
+ },
+ [SCREENS.SETTINGS.APP_DOWNLOAD_LINKS]: {
+ path: ROUTES.SETTINGS_APP_DOWNLOAD_LINKS,
+ exact: true,
+ },
+ [SCREENS.SETTINGS.PROFILE.CONTACT_METHODS]: {
+ path: ROUTES.SETTINGS_CONTACT_METHODS.route,
+ exact: true,
+ },
+ [SCREENS.SETTINGS.PROFILE.CONTACT_METHOD_DETAILS]: {
+ path: ROUTES.SETTINGS_CONTACT_METHOD_DETAILS.route,
+ },
+ [SCREENS.SETTINGS.LOUNGE_ACCESS]: {
+ path: ROUTES.SETTINGS_LOUNGE_ACCESS,
+ },
+ [SCREENS.SETTINGS.PROFILE.NEW_CONTACT_METHOD]: {
+ path: ROUTES.SETTINGS_NEW_CONTACT_METHOD.route,
+ exact: true,
+ },
+ [SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.INITIAL]: {
+ path: ROUTES.SETTINGS_PERSONAL_DETAILS,
+ exact: true,
+ },
+ [SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.LEGAL_NAME]: {
+ path: ROUTES.SETTINGS_PERSONAL_DETAILS_LEGAL_NAME,
+ exact: true,
+ },
+ [SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.DATE_OF_BIRTH]: {
+ path: ROUTES.SETTINGS_PERSONAL_DETAILS_DATE_OF_BIRTH,
+ exact: true,
+ },
+ [SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.ADDRESS]: {
+ path: ROUTES.SETTINGS_PERSONAL_DETAILS_ADDRESS,
+ exact: true,
+ },
+ [SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.ADDRESS_COUNTRY]: {
+ path: ROUTES.SETTINGS_PERSONAL_DETAILS_ADDRESS_COUNTRY.route,
+ exact: true,
+ },
+ [SCREENS.SETTINGS.TWO_FACTOR_AUTH]: {
+ path: ROUTES.SETTINGS_2FA.route,
+ exact: true,
+ },
+ [SCREENS.SETTINGS.PROFILE.STATUS]: {
+ path: ROUTES.SETTINGS_STATUS,
+ exact: true,
+ },
+ [SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER]: {
+ path: ROUTES.SETTINGS_STATUS_CLEAR_AFTER,
+ },
+ [SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_DATE]: {
+ path: ROUTES.SETTINGS_STATUS_CLEAR_AFTER_DATE,
+ },
+ [SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_TIME]: {
+ path: ROUTES.SETTINGS_STATUS_CLEAR_AFTER_TIME,
+ },
+ [SCREENS.WORKSPACE.CURRENCY]: {
+ path: ROUTES.WORKSPACE_OVERVIEW_CURRENCY.route,
+ },
+ [SCREENS.WORKSPACE.RATE_AND_UNIT]: {
+ path: ROUTES.WORKSPACE_RATE_AND_UNIT.route,
+ },
+ [SCREENS.WORKSPACE.INVITE]: {
+ path: ROUTES.WORKSPACE_INVITE.route,
+ },
+ [SCREENS.WORKSPACE.INVITE_MESSAGE]: {
+ path: ROUTES.WORKSPACE_INVITE_MESSAGE.route,
+ },
+ [SCREENS.REIMBURSEMENT_ACCOUNT]: {
+ path: ROUTES.BANK_ACCOUNT_WITH_STEP_TO_OPEN.route,
+ exact: true,
+ },
+ [SCREENS.GET_ASSISTANCE]: {
+ path: ROUTES.GET_ASSISTANCE.route,
+ },
+ [SCREENS.KEYBOARD_SHORTCUTS]: {
+ path: ROUTES.KEYBOARD_SHORTCUTS,
+ },
+ [SCREENS.WORKSPACE.NAME]: ROUTES.WORKSPACE_OVERVIEW_NAME.route,
+ },
+ },
+ [SCREENS.RIGHT_MODAL.PRIVATE_NOTES]: {
+ screens: {
+ [SCREENS.PRIVATE_NOTES.LIST]: ROUTES.PRIVATE_NOTES_LIST.route,
+ [SCREENS.PRIVATE_NOTES.EDIT]: ROUTES.PRIVATE_NOTES_EDIT.route,
+ },
+ },
+ [SCREENS.RIGHT_MODAL.REPORT_DETAILS]: {
+ screens: {
+ [SCREENS.REPORT_DETAILS.ROOT]: ROUTES.REPORT_WITH_ID_DETAILS.route,
+ [SCREENS.REPORT_DETAILS.SHARE_CODE]: ROUTES.REPORT_WITH_ID_DETAILS_SHARE_CODE.route,
+ },
+ },
+ [SCREENS.RIGHT_MODAL.REPORT_SETTINGS]: {
+ screens: {
+ [SCREENS.REPORT_SETTINGS.ROOT]: {
+ path: ROUTES.REPORT_SETTINGS.route,
+ },
+ [SCREENS.REPORT_SETTINGS.ROOM_NAME]: {
+ path: ROUTES.REPORT_SETTINGS_ROOM_NAME.route,
+ },
+ [SCREENS.REPORT_SETTINGS.NOTIFICATION_PREFERENCES]: {
+ path: ROUTES.REPORT_SETTINGS_NOTIFICATION_PREFERENCES.route,
+ },
+ [SCREENS.REPORT_SETTINGS.WRITE_CAPABILITY]: {
+ path: ROUTES.REPORT_SETTINGS_WRITE_CAPABILITY.route,
+ },
+ },
+ },
+ [SCREENS.RIGHT_MODAL.REPORT_WELCOME_MESSAGE]: {
+ screens: {
+ [SCREENS.REPORT_WELCOME_MESSAGE_ROOT]: ROUTES.REPORT_WELCOME_MESSAGE.route,
+ },
+ },
+ [SCREENS.RIGHT_MODAL.NEW_CHAT]: {
+ screens: {
+ [SCREENS.NEW_CHAT.ROOT]: {
+ path: ROUTES.NEW,
+ exact: true,
+ screens: {
+ [SCREENS.NEW_CHAT.NEW_CHAT]: {
+ path: ROUTES.NEW_CHAT,
+ exact: true,
+ },
+ [SCREENS.NEW_CHAT.NEW_ROOM]: {
+ path: ROUTES.NEW_ROOM,
+ exact: true,
+ },
+ },
+ },
+ },
+ },
+ [SCREENS.RIGHT_MODAL.NEW_TASK]: {
+ screens: {
+ [SCREENS.NEW_TASK.ROOT]: ROUTES.NEW_TASK,
+ [SCREENS.NEW_TASK.TASK_ASSIGNEE_SELECTOR]: ROUTES.NEW_TASK_ASSIGNEE,
+ [SCREENS.NEW_TASK.TASK_SHARE_DESTINATION_SELECTOR]: ROUTES.NEW_TASK_SHARE_DESTINATION,
+ [SCREENS.NEW_TASK.DETAILS]: ROUTES.NEW_TASK_DETAILS,
+ [SCREENS.NEW_TASK.TITLE]: ROUTES.NEW_TASK_TITLE,
+ [SCREENS.NEW_TASK.DESCRIPTION]: ROUTES.NEW_TASK_DESCRIPTION,
+ },
+ },
+ [SCREENS.RIGHT_MODAL.TEACHERS_UNITE]: {
+ screens: {
+ [SCREENS.SAVE_THE_WORLD.ROOT]: ROUTES.TEACHERS_UNITE,
+ [SCREENS.I_KNOW_A_TEACHER]: ROUTES.I_KNOW_A_TEACHER,
+ [SCREENS.INTRO_SCHOOL_PRINCIPAL]: ROUTES.INTRO_SCHOOL_PRINCIPAL,
+ [SCREENS.I_AM_A_TEACHER]: ROUTES.I_AM_A_TEACHER,
+ },
+ },
+ [SCREENS.RIGHT_MODAL.DETAILS]: {
+ screens: {
+ [SCREENS.DETAILS_ROOT]: ROUTES.DETAILS.route,
+ },
+ },
+ [SCREENS.RIGHT_MODAL.PROFILE]: {
+ screens: {
+ [SCREENS.PROFILE_ROOT]: ROUTES.PROFILE.route,
+ },
+ },
+ [SCREENS.RIGHT_MODAL.PARTICIPANTS]: {
+ screens: {
+ [SCREENS.REPORT_PARTICIPANTS_ROOT]: ROUTES.REPORT_PARTICIPANTS.route,
+ },
+ },
+ [SCREENS.RIGHT_MODAL.ROOM_INVITE]: {
+ screens: {
+ [SCREENS.ROOM_INVITE_ROOT]: ROUTES.ROOM_INVITE.route,
+ },
+ },
+ [SCREENS.RIGHT_MODAL.ROOM_MEMBERS]: {
+ screens: {
+ [SCREENS.ROOM_MEMBERS_ROOT]: ROUTES.ROOM_MEMBERS.route,
+ },
+ },
+ [SCREENS.RIGHT_MODAL.MONEY_REQUEST]: {
+ screens: {
+ [SCREENS.MONEY_REQUEST.START]: ROUTES.MONEY_REQUEST_START.route,
+ [SCREENS.MONEY_REQUEST.CREATE]: {
+ path: ROUTES.MONEY_REQUEST_CREATE.route,
+ exact: true,
+ screens: {
+ distance: {
+ path: ROUTES.MONEY_REQUEST_CREATE_TAB_DISTANCE.route,
+ exact: true,
+ },
+ manual: {
+ path: ROUTES.MONEY_REQUEST_CREATE_TAB_MANUAL.route,
+ exact: true,
+ },
+ scan: {
+ path: ROUTES.MONEY_REQUEST_CREATE_TAB_SCAN.route,
+ exact: true,
+ },
+ },
+ },
+ [SCREENS.MONEY_REQUEST.STEP_AMOUNT]: ROUTES.MONEY_REQUEST_STEP_AMOUNT.route,
+ [SCREENS.MONEY_REQUEST.STEP_CATEGORY]: ROUTES.MONEY_REQUEST_STEP_CATEGORY.route,
+ [SCREENS.MONEY_REQUEST.STEP_CONFIRMATION]: ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.route,
+ [SCREENS.MONEY_REQUEST.STEP_CURRENCY]: ROUTES.MONEY_REQUEST_STEP_CURRENCY.route,
+ [SCREENS.MONEY_REQUEST.STEP_DATE]: ROUTES.MONEY_REQUEST_STEP_DATE.route,
+ [SCREENS.MONEY_REQUEST.STEP_DESCRIPTION]: ROUTES.MONEY_REQUEST_STEP_DESCRIPTION.route,
+ [SCREENS.MONEY_REQUEST.STEP_DISTANCE]: ROUTES.MONEY_REQUEST_STEP_DISTANCE.route,
+ [SCREENS.MONEY_REQUEST.STEP_MERCHANT]: ROUTES.MONEY_REQUEST_STEP_MERCHANT.route,
+ [SCREENS.MONEY_REQUEST.STEP_PARTICIPANTS]: ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.route,
+ [SCREENS.MONEY_REQUEST.STEP_SCAN]: ROUTES.MONEY_REQUEST_STEP_SCAN.route,
+ [SCREENS.MONEY_REQUEST.STEP_TAG]: ROUTES.MONEY_REQUEST_STEP_TAG.route,
+ [SCREENS.MONEY_REQUEST.STEP_WAYPOINT]: ROUTES.MONEY_REQUEST_STEP_WAYPOINT.route,
+ [SCREENS.MONEY_REQUEST.ROOT]: {
+ path: ROUTES.MONEY_REQUEST.route,
+ exact: true,
+ screens: {
+ [SCREENS.MONEY_REQUEST.MANUAL_TAB]: {
+ path: ROUTES.MONEY_REQUEST_MANUAL_TAB,
+ exact: true,
+ },
+ [SCREENS.MONEY_REQUEST.SCAN_TAB]: {
+ path: ROUTES.MONEY_REQUEST_SCAN_TAB,
+ exact: true,
+ },
+ [SCREENS.MONEY_REQUEST.DISTANCE_TAB]: {
+ path: ROUTES.MONEY_REQUEST_DISTANCE_TAB.route,
+ exact: true,
+ },
+ },
+ },
+ [SCREENS.MONEY_REQUEST.AMOUNT]: ROUTES.MONEY_REQUEST_AMOUNT.route,
+ [SCREENS.MONEY_REQUEST.STEP_TAX_AMOUNT]: ROUTES.MONEY_REQUEST_STEP_TAX_AMOUNT.route,
+ [SCREENS.MONEY_REQUEST.STEP_TAX_RATE]: ROUTES.MONEY_REQUEST_STEP_TAX_RATE.route,
+ [SCREENS.MONEY_REQUEST.PARTICIPANTS]: ROUTES.MONEY_REQUEST_PARTICIPANTS.route,
+ [SCREENS.MONEY_REQUEST.CONFIRMATION]: ROUTES.MONEY_REQUEST_CONFIRMATION.route,
+ [SCREENS.MONEY_REQUEST.DATE]: ROUTES.MONEY_REQUEST_DATE.route,
+ [SCREENS.MONEY_REQUEST.CURRENCY]: ROUTES.MONEY_REQUEST_CURRENCY.route,
+ [SCREENS.MONEY_REQUEST.DESCRIPTION]: ROUTES.MONEY_REQUEST_DESCRIPTION.route,
+ [SCREENS.MONEY_REQUEST.CATEGORY]: ROUTES.MONEY_REQUEST_CATEGORY.route,
+ [SCREENS.MONEY_REQUEST.TAG]: ROUTES.MONEY_REQUEST_TAG.route,
+ [SCREENS.MONEY_REQUEST.MERCHANT]: ROUTES.MONEY_REQUEST_MERCHANT.route,
+ [SCREENS.MONEY_REQUEST.RECEIPT]: ROUTES.MONEY_REQUEST_RECEIPT.route,
+ [SCREENS.MONEY_REQUEST.DISTANCE]: ROUTES.MONEY_REQUEST_DISTANCE.route,
+ [SCREENS.IOU_SEND.ENABLE_PAYMENTS]: ROUTES.IOU_SEND_ENABLE_PAYMENTS,
+ [SCREENS.IOU_SEND.ADD_BANK_ACCOUNT]: ROUTES.IOU_SEND_ADD_BANK_ACCOUNT,
+ [SCREENS.IOU_SEND.ADD_DEBIT_CARD]: ROUTES.IOU_SEND_ADD_DEBIT_CARD,
+ },
+ },
+ [SCREENS.RIGHT_MODAL.SPLIT_DETAILS]: {
+ screens: {
+ [SCREENS.SPLIT_DETAILS.ROOT]: ROUTES.SPLIT_BILL_DETAILS.route,
+ [SCREENS.SPLIT_DETAILS.EDIT_REQUEST]: ROUTES.EDIT_SPLIT_BILL.route,
+ [SCREENS.SPLIT_DETAILS.EDIT_CURRENCY]: ROUTES.EDIT_SPLIT_BILL_CURRENCY.route,
+ },
+ },
+ [SCREENS.RIGHT_MODAL.TASK_DETAILS]: {
+ screens: {
+ [SCREENS.TASK.TITLE]: ROUTES.TASK_TITLE.route,
+ [SCREENS.TASK.DESCRIPTION]: ROUTES.TASK_DESCRIPTION.route,
+ [SCREENS.TASK.ASSIGNEE]: ROUTES.TASK_ASSIGNEE.route,
+ },
+ },
+ [SCREENS.RIGHT_MODAL.ADD_PERSONAL_BANK_ACCOUNT]: {
+ screens: {
+ [SCREENS.ADD_PERSONAL_BANK_ACCOUNT_ROOT]: ROUTES.BANK_ACCOUNT_PERSONAL,
+ },
+ },
+ [SCREENS.RIGHT_MODAL.ENABLE_PAYMENTS]: {
+ screens: {
+ [SCREENS.ENABLE_PAYMENTS_ROOT]: ROUTES.ENABLE_PAYMENTS,
+ },
+ },
+ [SCREENS.RIGHT_MODAL.WALLET_STATEMENT]: {
+ screens: {
+ [SCREENS.WALLET_STATEMENT_ROOT]: ROUTES.WALLET_STATEMENT_WITH_DATE,
+ },
+ },
+ [SCREENS.RIGHT_MODAL.FLAG_COMMENT]: {
+ screens: {
+ [SCREENS.FLAG_COMMENT_ROOT]: ROUTES.FLAG_COMMENT.route,
+ },
+ },
+ [SCREENS.RIGHT_MODAL.EDIT_REQUEST]: {
+ screens: {
+ [SCREENS.EDIT_REQUEST.ROOT]: ROUTES.EDIT_REQUEST.route,
+ [SCREENS.EDIT_REQUEST.CURRENCY]: ROUTES.EDIT_CURRENCY_REQUEST.route,
+ [SCREENS.EDIT_REQUEST.REPORT_FIELD]: ROUTES.EDIT_REPORT_FIELD_REQUEST.route,
+ },
+ },
+ [SCREENS.RIGHT_MODAL.SIGN_IN]: {
+ screens: {
+ [SCREENS.SIGN_IN_ROOT]: ROUTES.SIGN_IN_MODAL,
+ },
+ },
+ [SCREENS.RIGHT_MODAL.REFERRAL]: {
+ screens: {
+ [SCREENS.REFERRAL_DETAILS]: ROUTES.REFERRAL_DETAILS_MODAL.route,
+ },
+ },
+ [SCREENS.RIGHT_MODAL.PROCESS_MONEY_REQUEST_HOLD]: {
+ screens: {
+ [SCREENS.PROCESS_MONEY_REQUEST_HOLD_ROOT]: ROUTES.PROCESS_MONEY_REQUEST_HOLD,
+ },
+ },
+ },
+ },
+
+ [NAVIGATORS.FULL_SCREEN_NAVIGATOR]: {
+ screens: {
+ [SCREENS.SETTINGS.ROOT]: {
+ path: ROUTES.SETTINGS,
+ },
+ [SCREENS.SETTINGS_CENTRAL_PANE]: {
+ screens: {
+ [SCREENS.SETTINGS.SHARE_CODE]: {
+ path: ROUTES.SETTINGS_SHARE_CODE,
+ exact: true,
+ },
+ [SCREENS.SETTINGS.PROFILE.ROOT]: {
+ path: ROUTES.SETTINGS_PROFILE,
+ exact: true,
+ },
+ [SCREENS.SETTINGS.PREFERENCES.ROOT]: {
+ path: ROUTES.SETTINGS_PREFERENCES,
+ exact: true,
+ },
+ [SCREENS.SETTINGS.SECURITY]: {
+ path: ROUTES.SETTINGS_SECURITY,
+ exact: true,
+ },
+ [SCREENS.SETTINGS.WALLET.ROOT]: {
+ path: ROUTES.SETTINGS_WALLET,
+ exact: true,
+ },
+ [SCREENS.SETTINGS.ABOUT]: {
+ path: ROUTES.SETTINGS_ABOUT,
+ exact: true,
+ },
+ },
+ },
+ },
+ },
+ },
+};
+
+export default config;
diff --git a/src/libs/Navigation/linkingConfig/customGetPathFromState.ts b/src/libs/Navigation/linkingConfig/customGetPathFromState.ts
new file mode 100644
index 000000000000..76ad0bb06bd0
--- /dev/null
+++ b/src/libs/Navigation/linkingConfig/customGetPathFromState.ts
@@ -0,0 +1,27 @@
+import {getPathFromState} from '@react-navigation/native';
+import _ from 'lodash';
+import getPolicyIDFromState from '@libs/Navigation/getPolicyIDFromState';
+import getTopmostBottomTabRoute from '@libs/Navigation/getTopmostBottomTabRoute';
+import type {RootStackParamList, State} from '@libs/Navigation/types';
+import SCREENS from '@src/SCREENS';
+
+const removePolicyIDParamFromState = (state: State) => {
+ const stateCopy = _.cloneDeep(state);
+ const bottomTabRoute = getTopmostBottomTabRoute(stateCopy);
+ if (bottomTabRoute?.name === SCREENS.HOME && bottomTabRoute?.params && 'policyID' in bottomTabRoute?.params) {
+ delete bottomTabRoute.params.policyID;
+ }
+ return stateCopy;
+};
+
+const customGetPathFromState: typeof getPathFromState = (state, options) => {
+ const stateWithoutPolicyID = removePolicyIDParamFromState(state as State);
+
+ // For the Home page we should remove policyID from the params,
+ const path = getPathFromState(stateWithoutPolicyID, options);
+ const policyIDFromState = getPolicyIDFromState(state as State);
+ const isWorkspaceSettingsOpened = getTopmostBottomTabRoute(state as State)?.name === SCREENS.WORKSPACE.INITIAL && path.includes('workspace');
+ return `${policyIDFromState && !isWorkspaceSettingsOpened ? `/w/${policyIDFromState}` : ''}${path}`;
+};
+
+export default customGetPathFromState;
diff --git a/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts b/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts
new file mode 100644
index 000000000000..06e58282da70
--- /dev/null
+++ b/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts
@@ -0,0 +1,300 @@
+import type {NavigationState, PartialState} from '@react-navigation/native';
+import {getStateFromPath} from '@react-navigation/native';
+import {isAnonymousUser} from '@libs/actions/Session';
+import getIsSmallScreenWidth from '@libs/getIsSmallScreenWidth';
+import getTopmostNestedRHPRoute from '@libs/Navigation/getTopmostNestedRHPRoute';
+import type {BottomTabName, CentralPaneName, FullScreenName, NavigationPartialRoute, RootStackParamList} from '@libs/Navigation/types';
+import {extractPolicyIDFromPath, getPathWithoutPolicyID} from '@libs/PolicyUtils';
+import NAVIGATORS from '@src/NAVIGATORS';
+import SCREENS from '@src/SCREENS';
+import CENTRAL_PANE_TO_RHP_MAPPING from './CENTRAL_PANE_TO_RHP_MAPPING';
+import config from './config';
+import FULL_SCREEN_TO_RHP_MAPPING from './FULL_SCREEN_TO_RHP_MAPPING';
+import getMatchingBottomTabRouteForState from './getMatchingBottomTabRouteForState';
+import getMatchingCentralPaneRouteForState from './getMatchingCentralPaneRouteForState';
+import replacePathInNestedState from './replacePathInNestedState';
+
+type Metainfo = {
+ // Sometimes modal screens don't have information about what should be visible under the overlay.
+ // That means such screen can have different screens under the overlay depending on what was already in the state.
+ // If the screens in the bottom tab and central pane are not mandatory for this state, we want to have this information.
+ // It will help us later with creating proper diff betwen current and desired state.
+ isCentralPaneAndBottomTabMandatory: boolean;
+ isFullScreenNavigatorMandatory: boolean;
+};
+
+type GetAdaptedStateReturnType = {
+ adaptedState: ReturnType;
+ metainfo: Metainfo;
+};
+
+type GetAdaptedStateFromPath = (...args: Parameters) => GetAdaptedStateReturnType;
+
+// The function getPathFromState that we are using in some places isn't working correctly without defined index.
+const getRoutesWithIndex = (routes: NavigationPartialRoute[]): PartialState => ({routes, index: routes.length - 1});
+
+const addPolicyIDToRoute = (route: NavigationPartialRoute, policyID?: string) => {
+ const routeWithPolicyID = {...route};
+ if (!routeWithPolicyID.params) {
+ routeWithPolicyID.params = {policyID};
+ return routeWithPolicyID;
+ }
+
+ if ('policyID' in routeWithPolicyID.params && !!routeWithPolicyID.params.policyID) {
+ return routeWithPolicyID;
+ }
+
+ routeWithPolicyID.params = {...routeWithPolicyID.params, policyID};
+
+ return routeWithPolicyID;
+};
+
+function createBottomTabNavigator(route: NavigationPartialRoute, policyID?: string): NavigationPartialRoute {
+ const routesForBottomTabNavigator: Array> = [{name: SCREENS.HOME, params: {policyID}}];
+
+ if (route.name !== SCREENS.HOME) {
+ // If the generated state requires tab other than HOME, we need to insert it.
+ routesForBottomTabNavigator.push(addPolicyIDToRoute(route, policyID) as NavigationPartialRoute);
+ }
+
+ return {
+ name: NAVIGATORS.BOTTOM_TAB_NAVIGATOR,
+ state: getRoutesWithIndex(routesForBottomTabNavigator),
+ };
+}
+
+function createCentralPaneNavigator(route: NavigationPartialRoute): NavigationPartialRoute {
+ return {
+ name: NAVIGATORS.CENTRAL_PANE_NAVIGATOR,
+ state: getRoutesWithIndex([route]),
+ };
+}
+
+function createFullScreenNavigator(route: NavigationPartialRoute): NavigationPartialRoute {
+ const routes = [];
+
+ routes.push({name: SCREENS.SETTINGS.ROOT});
+ routes.push({
+ name: SCREENS.SETTINGS_CENTRAL_PANE,
+ state: getRoutesWithIndex([route]),
+ });
+
+ return {
+ name: NAVIGATORS.FULL_SCREEN_NAVIGATOR,
+ state: getRoutesWithIndex(routes),
+ };
+}
+
+// This function will return CentralPaneNavigator route or FullScreenNavigator route.
+function getMatchingRootRouteForRHPRoute(
+ route: NavigationPartialRoute,
+): NavigationPartialRoute | undefined {
+ // Check for backTo param. One screen with different backTo value may need diferent screens visible under the overlay.
+ if (route.params && 'backTo' in route.params && typeof route.params.backTo === 'string') {
+ const stateForBackTo = getStateFromPath(route.params.backTo, config);
+ if (stateForBackTo) {
+ // eslint-disable-next-line @typescript-eslint/no-shadow
+ const rhpNavigator = stateForBackTo.routes.find((route) => route.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR);
+
+ const centralPaneOrFullScreenNavigator = stateForBackTo.routes.find(
+ // eslint-disable-next-line @typescript-eslint/no-shadow
+ (route) => route.name === NAVIGATORS.CENTRAL_PANE_NAVIGATOR || route.name === NAVIGATORS.FULL_SCREEN_NAVIGATOR,
+ );
+
+ // If there is rhpNavigator in the state generated for backTo url, we want to get root route matching to this rhp screen.
+ if (rhpNavigator && rhpNavigator.state) {
+ const topmostNestedRHPRoute = getTopmostNestedRHPRoute(stateForBackTo);
+ if (topmostNestedRHPRoute) {
+ return getMatchingRootRouteForRHPRoute(topmostNestedRHPRoute);
+ }
+ }
+
+ // If we know that backTo targets the root route (central pane or full screen) we want to use it.
+ if (centralPaneOrFullScreenNavigator && centralPaneOrFullScreenNavigator.state) {
+ return centralPaneOrFullScreenNavigator as NavigationPartialRoute;
+ }
+ }
+ }
+
+ // Check for CentralPaneNavigator
+ for (const [centralPaneName, RHPNames] of Object.entries(CENTRAL_PANE_TO_RHP_MAPPING)) {
+ if (RHPNames.includes(route.name)) {
+ return createCentralPaneNavigator({name: centralPaneName as CentralPaneName, params: route.params});
+ }
+ }
+
+ // Check for FullScreenNavigator
+ for (const [fullScreenName, RHPNames] of Object.entries(FULL_SCREEN_TO_RHP_MAPPING)) {
+ if (RHPNames && RHPNames.includes(route.name)) {
+ return createFullScreenNavigator({name: fullScreenName as FullScreenName, params: route.params});
+ }
+ }
+}
+
+function getAdaptedState(state: PartialState>, policyID?: string): GetAdaptedStateReturnType {
+ const isSmallScreenWidth = getIsSmallScreenWidth();
+ const metainfo = {
+ isCentralPaneAndBottomTabMandatory: true,
+ isFullScreenNavigatorMandatory: true,
+ };
+
+ // We need to check what is defined to know what we need to add.
+ const bottomTabNavigator = state.routes.find((route) => route.name === NAVIGATORS.BOTTOM_TAB_NAVIGATOR);
+ const centralPaneNavigator = state.routes.find((route) => route.name === NAVIGATORS.CENTRAL_PANE_NAVIGATOR);
+ const fullScreenNavigator = state.routes.find((route) => route.name === NAVIGATORS.FULL_SCREEN_NAVIGATOR);
+ const rhpNavigator = state.routes.find((route) => route.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR);
+ const lhpNavigator = state.routes.find((route) => route.name === NAVIGATORS.LEFT_MODAL_NAVIGATOR);
+ if (rhpNavigator) {
+ // Routes
+ // - matching bottom tab
+ // - matching root route for rhp
+ // - found rhp
+
+ // This one will be defined because rhpNavigator is defined.
+ const topmostNestedRHPRoute = getTopmostNestedRHPRoute(state);
+ const routes = [];
+
+ if (topmostNestedRHPRoute) {
+ let matchingRootRoute = getMatchingRootRouteForRHPRoute(topmostNestedRHPRoute);
+
+ // This may happen if this RHP doens't have a route that should be under the overlay defined.
+ if (!matchingRootRoute) {
+ metainfo.isCentralPaneAndBottomTabMandatory = false;
+ metainfo.isFullScreenNavigatorMandatory = false;
+ matchingRootRoute = createCentralPaneNavigator({name: SCREENS.REPORT});
+ }
+
+ // If the root route is type of FullScreenNavigator, the default bottom tab will be added.
+ const matchingBottomTabRoute = getMatchingBottomTabRouteForState({routes: [matchingRootRoute]});
+ routes.push(createBottomTabNavigator(matchingBottomTabRoute, policyID));
+ routes.push(matchingRootRoute);
+ }
+
+ routes.push(rhpNavigator);
+ return {
+ adaptedState: getRoutesWithIndex(routes),
+ metainfo,
+ };
+ }
+ if (lhpNavigator) {
+ // Routes
+ // - default bottom tab
+ // - default central pane on desktop layout
+ // - found lhp
+
+ // Currently there is only the search and workspace switcher in LHP both can have any central pane under the overlay.
+ metainfo.isCentralPaneAndBottomTabMandatory = false;
+ metainfo.isFullScreenNavigatorMandatory = false;
+ const routes = [];
+ routes.push(
+ createBottomTabNavigator(
+ {
+ name: SCREENS.HOME,
+ },
+ policyID,
+ ),
+ );
+ if (!isSmallScreenWidth) {
+ routes.push(
+ createCentralPaneNavigator({
+ name: SCREENS.REPORT,
+ }),
+ );
+ }
+ routes.push(lhpNavigator);
+
+ return {
+ adaptedState: getRoutesWithIndex(routes),
+ metainfo,
+ };
+ }
+ if (fullScreenNavigator) {
+ // Routes
+ // - default bottom tab
+ // - default central pane on desktop layout
+ // - found fullscreen
+
+ // Full screen navigator can have any central pane and bottom tab under. They will be covered anyway.
+ metainfo.isCentralPaneAndBottomTabMandatory = false;
+
+ const routes = [];
+ routes.push(
+ createBottomTabNavigator(
+ {
+ name: SCREENS.HOME,
+ },
+ policyID,
+ ),
+ );
+ if (!isSmallScreenWidth) {
+ routes.push(createCentralPaneNavigator({name: SCREENS.REPORT}));
+ }
+ routes.push(fullScreenNavigator);
+
+ return {
+ adaptedState: getRoutesWithIndex(routes),
+ metainfo,
+ };
+ }
+ if (centralPaneNavigator) {
+ // Routes
+ // - matching bottom tab
+ // - found central pane
+ const routes = [];
+ const matchingBottomTabRoute = getMatchingBottomTabRouteForState(state);
+ routes.push(createBottomTabNavigator(matchingBottomTabRoute, policyID));
+ routes.push(centralPaneNavigator);
+
+ return {
+ adaptedState: getRoutesWithIndex(routes),
+ metainfo,
+ };
+ }
+ if (bottomTabNavigator) {
+ // Routes
+ // - found bottom tab
+ // - matching central pane on desktop layout
+ if (isSmallScreenWidth) {
+ return {
+ adaptedState: state,
+ metainfo,
+ };
+ }
+
+ const routes = [...state.routes];
+ const matchingCentralPaneRoute = getMatchingCentralPaneRouteForState(state);
+ if (matchingCentralPaneRoute) {
+ routes.push(createCentralPaneNavigator(matchingCentralPaneRoute));
+ }
+
+ return {
+ adaptedState: getRoutesWithIndex(routes),
+ metainfo,
+ };
+ }
+
+ return {
+ adaptedState: state,
+ metainfo,
+ };
+}
+
+const getAdaptedStateFromPath: GetAdaptedStateFromPath = (path, options) => {
+ const normalizedPath = !path.startsWith('/') ? `/${path}` : path;
+ const pathWithoutPolicyID = getPathWithoutPolicyID(normalizedPath);
+ const isAnonymous = isAnonymousUser();
+
+ // Anonymous users don't have access to workspaces
+ const policyID = isAnonymous ? undefined : extractPolicyIDFromPath(path);
+
+ const state = getStateFromPath(pathWithoutPolicyID, options) as PartialState>;
+ replacePathInNestedState(state, path);
+
+ if (state === undefined) {
+ throw new Error('Unable to parse path');
+ }
+ return getAdaptedState(state, policyID);
+};
+
+export default getAdaptedStateFromPath;
+export type {Metainfo};
diff --git a/src/libs/Navigation/linkingConfig/getMatchingBottomTabRouteForState.ts b/src/libs/Navigation/linkingConfig/getMatchingBottomTabRouteForState.ts
new file mode 100644
index 000000000000..ef4cd65942b0
--- /dev/null
+++ b/src/libs/Navigation/linkingConfig/getMatchingBottomTabRouteForState.ts
@@ -0,0 +1,23 @@
+import getTopmostCentralPaneRoute from '@libs/Navigation/getTopmostCentralPaneRoute';
+import type {BottomTabName, NavigationPartialRoute, RootStackParamList, State} from '@libs/Navigation/types';
+import SCREENS from '@src/SCREENS';
+import {CENTRAL_PANE_TO_TAB_MAPPING} from './TAB_TO_CENTRAL_PANE_MAPPING';
+
+// Get the route that matches the topmost central pane route in the navigation stack. e.g REPORT -> HOME
+function getMatchingBottomTabRouteForState(state: State, policyID?: string): NavigationPartialRoute {
+ const paramsWithPolicyID = policyID ? {policyID} : undefined;
+ const defaultRoute = {name: SCREENS.HOME, params: paramsWithPolicyID};
+ const topmostCentralPaneRoute = getTopmostCentralPaneRoute(state);
+
+ if (topmostCentralPaneRoute === undefined) {
+ return defaultRoute;
+ }
+
+ const tabName = CENTRAL_PANE_TO_TAB_MAPPING[topmostCentralPaneRoute.name];
+ if (tabName === SCREENS.WORKSPACE.INITIAL) {
+ return {name: tabName, params: topmostCentralPaneRoute.params};
+ }
+ return {name: tabName, params: paramsWithPolicyID};
+}
+
+export default getMatchingBottomTabRouteForState;
diff --git a/src/libs/Navigation/linkingConfig/getMatchingCentralPaneRouteForState.ts b/src/libs/Navigation/linkingConfig/getMatchingCentralPaneRouteForState.ts
new file mode 100644
index 000000000000..55ccca73a389
--- /dev/null
+++ b/src/libs/Navigation/linkingConfig/getMatchingCentralPaneRouteForState.ts
@@ -0,0 +1,55 @@
+import getTopmostBottomTabRoute from '@libs/Navigation/getTopmostBottomTabRoute';
+import type {CentralPaneName, NavigationPartialRoute, RootStackParamList, State} from '@libs/Navigation/types';
+import NAVIGATORS from '@src/NAVIGATORS';
+import SCREENS from '@src/SCREENS';
+import TAB_TO_CENTRAL_PANE_MAPPING from './TAB_TO_CENTRAL_PANE_MAPPING';
+
+/**
+ * @param state - react-navigation state
+ */
+const getTopMostReportIDFromRHP = (state: State): string => {
+ if (!state) {
+ return '';
+ }
+
+ const topmostRightPane = state.routes.filter((route) => route.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR).at(-1);
+
+ if (topmostRightPane?.state) {
+ return getTopMostReportIDFromRHP(topmostRightPane.state);
+ }
+
+ const topmostRoute = state.routes.at(-1);
+
+ if (topmostRoute?.state) {
+ return getTopMostReportIDFromRHP(topmostRoute.state);
+ }
+
+ if (topmostRoute?.params && 'reportID' in topmostRoute.params && typeof topmostRoute.params.reportID === 'string') {
+ return topmostRoute.params.reportID;
+ }
+
+ return '';
+};
+
+// Get matching central pane route for bottom tab navigator. e.g HOME -> REPORT
+function getMatchingCentralPaneRouteForState(state: State): NavigationPartialRoute | undefined {
+ const topmostBottomTabRoute = getTopmostBottomTabRoute(state);
+
+ if (!topmostBottomTabRoute) {
+ return;
+ }
+
+ const centralPaneName = TAB_TO_CENTRAL_PANE_MAPPING[topmostBottomTabRoute.name][0];
+
+ if (topmostBottomTabRoute.name === SCREENS.WORKSPACE.INITIAL) {
+ return {name: centralPaneName, params: topmostBottomTabRoute.params};
+ }
+
+ if (topmostBottomTabRoute.name === SCREENS.HOME) {
+ return {name: centralPaneName, params: {reportID: getTopMostReportIDFromRHP(state)}};
+ }
+
+ return {name: centralPaneName};
+}
+
+export default getMatchingCentralPaneRouteForState;
diff --git a/src/libs/Navigation/linkingConfig/index.ts b/src/libs/Navigation/linkingConfig/index.ts
new file mode 100644
index 000000000000..95fe2f3b6c55
--- /dev/null
+++ b/src/libs/Navigation/linkingConfig/index.ts
@@ -0,0 +1,30 @@
+/* eslint-disable @typescript-eslint/naming-convention */
+import type {LinkingOptions} from '@react-navigation/native';
+import type {RootStackParamList} from '@navigation/types';
+import CONST from '@src/CONST';
+import config from './config';
+import customGetPathFromState from './customGetPathFromState';
+import getAdaptedStateFromPath from './getAdaptedStateFromPath';
+
+const linkingConfig: LinkingOptions = {
+ getStateFromPath: (...args) => {
+ const {adaptedState} = getAdaptedStateFromPath(...args);
+
+ // ResultState | undefined is the type this function expect.
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-return
+ return adaptedState;
+ },
+ getPathFromState: customGetPathFromState,
+ prefixes: [
+ 'app://-/',
+ 'new-expensify://',
+ 'https://www.expensify.cash',
+ 'https://staging.expensify.cash',
+ 'https://dev.new.expensify.com',
+ CONST.NEW_EXPENSIFY_URL,
+ CONST.STAGING_NEW_EXPENSIFY_URL,
+ ],
+ config,
+};
+
+export default linkingConfig;
diff --git a/src/libs/Navigation/linkingConfig/replacePathInNestedState.ts b/src/libs/Navigation/linkingConfig/replacePathInNestedState.ts
new file mode 100644
index 000000000000..6b50cd76446e
--- /dev/null
+++ b/src/libs/Navigation/linkingConfig/replacePathInNestedState.ts
@@ -0,0 +1,15 @@
+/* eslint-disable @typescript-eslint/naming-convention */
+import {findFocusedRoute} from '@react-navigation/native';
+import type {NavigationState, PartialState} from '@react-navigation/native';
+import type {RootStackParamList} from '@libs/Navigation/types';
+
+function replacePathInNestedState(state: PartialState>, path: string) {
+ const found = findFocusedRoute(state);
+ if (!found) {
+ return;
+ }
+
+ // @ts-expect-error Updating read only property
+ found.path = path;
+}
+export default replacePathInNestedState;
diff --git a/src/libs/Navigation/switchPolicyID.ts b/src/libs/Navigation/switchPolicyID.ts
new file mode 100644
index 000000000000..4b9259c76ad7
--- /dev/null
+++ b/src/libs/Navigation/switchPolicyID.ts
@@ -0,0 +1,160 @@
+import {getActionFromState} from '@react-navigation/core';
+import type {NavigationAction, NavigationContainerRef, NavigationState, PartialState} from '@react-navigation/native';
+import {getPathFromState} from '@react-navigation/native';
+import type {ValueOf, Writable} from 'type-fest';
+import getIsSmallScreenWidth from '@libs/getIsSmallScreenWidth';
+import CONST from '@src/CONST';
+import NAVIGATORS from '@src/NAVIGATORS';
+import type {Route} from '@src/ROUTES';
+import SCREENS from '@src/SCREENS';
+import getStateFromPath from './getStateFromPath';
+import getTopmostCentralPaneRoute from './getTopmostCentralPaneRoute';
+import linkingConfig from './linkingConfig';
+import TAB_TO_CENTRAL_PANE_MAPPING from './linkingConfig/TAB_TO_CENTRAL_PANE_MAPPING';
+import type {NavigationRoot, RootStackParamList, StackNavigationAction, State, SwitchPolicyIDParams} from './types';
+
+type ActionPayloadParams = {
+ screen?: string;
+ params?: unknown;
+ path?: string;
+};
+
+type CentralPaneRouteParams = Record & {policyID?: string; reportID?: string};
+
+function checkIfActionPayloadNameIsEqual(action: Writable, screenName: string) {
+ return action?.payload && 'name' in action?.payload && action?.payload?.name === screenName;
+}
+
+function getActionForBottomTabNavigator(action: StackNavigationAction, state: NavigationState, policyID?: string): Writable | undefined {
+ const bottomTabNavigatorRoute = state.routes.at(0);
+
+ if (!bottomTabNavigatorRoute || bottomTabNavigatorRoute.state === undefined || !action || action.type !== CONST.NAVIGATION.ACTION_TYPE.NAVIGATE) {
+ return;
+ }
+
+ const params = action.payload.params as ActionPayloadParams;
+ let payloadParams = params?.params as Record;
+ let screen = params.screen;
+
+ // Case when the user is on the AllSettingsScreen and selects the specific workspace. The user is redirected then to the specific workspace settings.
+ if (screen === SCREENS.ALL_SETTINGS && policyID) {
+ screen = SCREENS.WORKSPACE.INITIAL;
+ }
+
+ // Alternative case when the user is on the specific workspace settings screen and selects "All" workspace.
+ else if (!policyID && screen === SCREENS.WORKSPACE.INITIAL) {
+ screen = SCREENS.ALL_SETTINGS;
+ }
+
+ if (!payloadParams) {
+ payloadParams = {policyID};
+ } else {
+ payloadParams.policyID = policyID;
+ }
+
+ return {
+ type: CONST.NAVIGATION.ACTION_TYPE.PUSH,
+ payload: {
+ name: screen,
+ params: payloadParams,
+ },
+ target: bottomTabNavigatorRoute.state.key,
+ };
+}
+
+export default function switchPolicyID(navigation: NavigationContainerRef | null, {policyID, route, isPolicyAdmin = false}: SwitchPolicyIDParams) {
+ if (!navigation) {
+ throw new Error("Couldn't find a navigation object. Is your component inside a screen in a navigator?");
+ }
+
+ let root: NavigationRoot = navigation;
+ let current: NavigationRoot | undefined;
+
+ // Traverse up to get the root navigation
+ // eslint-disable-next-line no-cond-assign
+ while ((current = root.getParent())) {
+ root = current;
+ }
+
+ const rootState = navigation.getRootState() as NavigationState;
+ const newPath = route ?? getPathFromState({routes: rootState.routes} as State, linkingConfig.config);
+ const stateFromPath = getStateFromPath(newPath as Route) as PartialState>;
+ const action: StackNavigationAction = getActionFromState(stateFromPath, linkingConfig.config);
+
+ const actionForBottomTabNavigator = getActionForBottomTabNavigator(action, rootState, policyID);
+
+ if (!actionForBottomTabNavigator) {
+ return;
+ }
+
+ root.dispatch(actionForBottomTabNavigator);
+
+ // If path is passed to this method, it means that screen is pushed to the Central Pane from another place in code
+ if (route) {
+ return;
+ }
+
+ // If the layout is wide we need to push matching central pane route to the stack.
+ if (!getIsSmallScreenWidth()) {
+ // Case when the user selects "All" workspace from the specific workspace settings
+ if (checkIfActionPayloadNameIsEqual(actionForBottomTabNavigator, SCREENS.ALL_SETTINGS) && !policyID) {
+ root.dispatch({
+ type: CONST.NAVIGATION.ACTION_TYPE.PUSH,
+ payload: {
+ name: NAVIGATORS.CENTRAL_PANE_NAVIGATOR,
+ params: {
+ screen: SCREENS.SETTINGS.WORKSPACES,
+ params: undefined,
+ },
+ },
+ });
+ } else {
+ const topmostCentralPaneRoute = getTopmostCentralPaneRoute(rootState);
+ let screen = topmostCentralPaneRoute?.name;
+ const params: CentralPaneRouteParams = {...topmostCentralPaneRoute?.params};
+ const isWorkspaceScreen = screen && Object.values(SCREENS.WORKSPACE).includes(screen as ValueOf);
+
+ // Only workspace settings screens have to store the policyID in the params.
+ // In other case, the policyID is read from the BottomTab params.
+ if (!isWorkspaceScreen) {
+ delete params.policyID;
+ } else {
+ params.policyID = policyID;
+ }
+
+ // We need to redirect non admin users to overview screen, when switching workspace.
+ if (!isPolicyAdmin && isWorkspaceScreen && screen !== SCREENS.WORKSPACE.OVERVIEW) {
+ screen = SCREENS.WORKSPACE.OVERVIEW;
+ }
+
+ // If the user is on the home page and changes the current workspace, then should be displayed a report from the selected workspace.
+ // To achieve that, it's necessary to navigate without the reportID param.
+ if (checkIfActionPayloadNameIsEqual(actionForBottomTabNavigator, SCREENS.HOME)) {
+ delete params.reportID;
+ }
+
+ // When the user from the screen with the workspaces list opens the specific workspace from the switcher, the appropriate settings screen has to be pushed to the CentralPane.
+ if (screen === SCREENS.SETTINGS.WORKSPACES && policyID) {
+ screen = TAB_TO_CENTRAL_PANE_MAPPING[SCREENS.WORKSPACE.INITIAL][0];
+ params.policyID = policyID;
+ }
+
+ root.dispatch({
+ type: CONST.NAVIGATION.ACTION_TYPE.PUSH,
+ payload: {
+ name: NAVIGATORS.CENTRAL_PANE_NAVIGATOR,
+ params: {
+ screen,
+ params,
+ },
+ },
+ });
+ }
+ } else {
+ // If the layout is small we need to pop everything from the central pane so the bottom tab navigator is visible.
+ root.dispatch({
+ type: 'POP_TO_TOP',
+ target: rootState.key,
+ });
+ }
+}
diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts
index 2371c764f42a..3c4cf17853f1 100644
--- a/src/libs/Navigation/types.ts
+++ b/src/libs/Navigation/types.ts
@@ -1,5 +1,15 @@
/* eslint-disable @typescript-eslint/naming-convention */
-import type {CommonActions, NavigationContainerRefWithCurrent, NavigationHelpers, NavigationState, NavigatorScreenParams, PartialRoute, Route} from '@react-navigation/native';
+import type {
+ CommonActions,
+ NavigationContainerRefWithCurrent,
+ NavigationHelpers,
+ NavigationState,
+ NavigatorScreenParams,
+ ParamListBase,
+ PartialRoute,
+ PartialState,
+ Route,
+} from '@react-navigation/native';
import type {ValueOf} from 'type-fest';
import type CONST from '@src/CONST';
import type NAVIGATORS from '@src/NAVIGATORS';
@@ -31,8 +41,9 @@ type ActionNavigate = {
type StackNavigationAction = GoBackAction | ResetAction | SetParamsAction | ActionNavigate | undefined;
type NavigationStateRoute = NavigationState['routes'][number];
-type NavigationPartialRoute = PartialRoute>;
+type NavigationPartialRoute = PartialRoute>;
type StateOrRoute = NavigationState | NavigationStateRoute | NavigationPartialRoute;
+type State = NavigationState | PartialState>;
type CentralPaneNavigatorParamList = {
[SCREENS.REPORT]: {
@@ -40,12 +51,38 @@ type CentralPaneNavigatorParamList = {
reportID: string;
openOnAdminRoom?: boolean;
};
+
+ [SCREENS.SETTINGS.WORKSPACES]: undefined;
+ [SCREENS.WORKSPACE.OVERVIEW]: {
+ policyID: string;
+ };
+ [SCREENS.WORKSPACE.CARD]: {
+ policyID: string;
+ };
+ [SCREENS.WORKSPACE.REIMBURSE]: {
+ policyID: string;
+ };
+ [SCREENS.WORKSPACE.BILLS]: {
+ policyID: string;
+ };
+ [SCREENS.WORKSPACE.INVOICES]: {
+ policyID: string;
+ };
+ [SCREENS.WORKSPACE.TRAVEL]: {
+ policyID: string;
+ };
+ [SCREENS.WORKSPACE.MEMBERS]: {
+ policyID: string;
+ };
+};
+
+type WorkspaceSwitcherNavigatorParamList = {
+ [SCREENS.WORKSPACE_SWITCHER.ROOT]: undefined;
};
type SettingsNavigatorParamList = {
[SCREENS.SETTINGS.ROOT]: undefined;
[SCREENS.SETTINGS.SHARE_CODE]: undefined;
- [SCREENS.SETTINGS.WORKSPACES]: undefined;
[SCREENS.SETTINGS.PROFILE.ROOT]: undefined;
[SCREENS.SETTINGS.PROFILE.PRONOUNS]: undefined;
[SCREENS.SETTINGS.PROFILE.DISPLAY_NAME]: undefined;
@@ -86,45 +123,22 @@ type SettingsNavigatorParamList = {
[SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER]: undefined;
[SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_DATE]: undefined;
[SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_TIME]: undefined;
- [SCREENS.WORKSPACE.INITIAL]: undefined;
- [SCREENS.WORKSPACE.SETTINGS]: undefined;
[SCREENS.WORKSPACE.CURRENCY]: undefined;
- [SCREENS.WORKSPACE.CARD]: {
- policyID: string;
- };
- [SCREENS.WORKSPACE.REIMBURSE]: {
- policyID: string;
- };
+ [SCREENS.WORKSPACE.NAME]: undefined;
[SCREENS.WORKSPACE.RATE_AND_UNIT]: undefined;
- [SCREENS.WORKSPACE.BILLS]: {
- policyID: string;
- };
- [SCREENS.WORKSPACE.INVOICES]: {
- policyID: string;
- };
- [SCREENS.WORKSPACE.TRAVEL]: {
- policyID: string;
- };
- [SCREENS.WORKSPACE.MEMBERS]: {
- policyID: string;
- };
[SCREENS.WORKSPACE.INVITE]: {
policyID: string;
};
[SCREENS.WORKSPACE.INVITE_MESSAGE]: {
policyID: string;
};
- [SCREENS.REIMBURSEMENT_ACCOUNT]: {
- stepToOpen: string;
- policyID: string;
- };
[SCREENS.GET_ASSISTANCE]: {
backTo: Routes;
};
[SCREENS.SETTINGS.TWO_FACTOR_AUTH]: undefined;
[SCREENS.SETTINGS.REPORT_CARD_LOST_OR_DAMAGED]: undefined;
[SCREENS.KEYBOARD_SHORTCUTS]: undefined;
-};
+} & ReimbursementAccountNavigatorParamList;
type NewChatNavigatorParamList = {
[SCREENS.NEW_CHAT.ROOT]: undefined;
@@ -342,6 +356,7 @@ type PrivateNotesNavigatorParamList = {
type LeftModalNavigatorParamList = {
[SCREENS.LEFT_MODAL.SEARCH]: NavigatorScreenParams;
+ [SCREENS.LEFT_MODAL.WORKSPACE_SWITCHER]: NavigatorScreenParams;
};
type RightModalNavigatorParamList = {
@@ -371,8 +386,28 @@ type RightModalNavigatorParamList = {
[SCREENS.RIGHT_MODAL.PRIVATE_NOTES]: NavigatorScreenParams;
};
-type PublicScreensParamList = {
+type SettingsCentralPaneNavigatorParamList = {
+ [SCREENS.SETTINGS.SHARE_CODE]: undefined;
+ [SCREENS.SETTINGS.PROFILE.ROOT]: undefined;
+ [SCREENS.SETTINGS.PREFERENCES.ROOT]: undefined;
+ [SCREENS.SETTINGS.SECURITY]: undefined;
+ [SCREENS.SETTINGS.WALLET.ROOT]: undefined;
+ [SCREENS.SETTINGS.ABOUT]: undefined;
+};
+
+type FullScreenNavigatorParamList = {
+ [SCREENS.SETTINGS.ROOT]: undefined;
+ [SCREENS.SETTINGS_CENTRAL_PANE]: NavigatorScreenParams;
+};
+
+type BottomTabNavigatorParamList = {
[SCREENS.HOME]: undefined;
+ [SCREENS.ALL_SETTINGS]: undefined;
+ [SCREENS.WORKSPACE.INITIAL]: undefined;
+};
+
+type PublicScreensParamList = {
+ [NAVIGATORS.BOTTOM_TAB_NAVIGATOR]: NavigatorScreenParams;
[SCREENS.TRANSITION_BETWEEN_APPS]: {
email?: string;
error?: string;
@@ -394,7 +429,7 @@ type PublicScreensParamList = {
};
type AuthScreensParamList = {
- [SCREENS.HOME]: undefined;
+ [NAVIGATORS.BOTTOM_TAB_NAVIGATOR]: NavigatorScreenParams;
[NAVIGATORS.CENTRAL_PANE_NAVIGATOR]: NavigatorScreenParams;
[SCREENS.VALIDATE_LOGIN]: {
accountID: string;
@@ -423,20 +458,38 @@ type AuthScreensParamList = {
[SCREENS.NOT_FOUND]: undefined;
[NAVIGATORS.LEFT_MODAL_NAVIGATOR]: NavigatorScreenParams;
[NAVIGATORS.RIGHT_MODAL_NAVIGATOR]: NavigatorScreenParams;
+ [NAVIGATORS.FULL_SCREEN_NAVIGATOR]: NavigatorScreenParams;
[SCREENS.DESKTOP_SIGN_IN_REDIRECT]: undefined;
};
type RootStackParamList = PublicScreensParamList & AuthScreensParamList;
+type BottomTabName = keyof BottomTabNavigatorParamList;
+
+type CentralPaneName = keyof CentralPaneNavigatorParamList;
+
+type FullScreenName = keyof SettingsCentralPaneNavigatorParamList;
+
+type SwitchPolicyIDParams = {
+ policyID?: string;
+ route?: Routes;
+ isPolicyAdmin?: boolean;
+};
+
export type {
NavigationRef,
StackNavigationAction,
CentralPaneNavigatorParamList,
+ BottomTabName,
+ CentralPaneName,
+ FullScreenName,
RootStackParamList,
StateOrRoute,
NavigationStateRoute,
+ NavigationPartialRoute,
NavigationRoot,
AuthScreensParamList,
+ BottomTabNavigatorParamList,
LeftModalNavigatorParamList,
RightModalNavigatorParamList,
PublicScreensParamList,
@@ -465,4 +518,7 @@ export type {
SignInNavigatorParamList,
ReferralDetailsNavigatorParamList,
ReimbursementAccountNavigatorParamList,
+ State,
+ WorkspaceSwitcherNavigatorParamList,
+ SwitchPolicyIDParams,
};
diff --git a/src/libs/Notification/PushNotification/subscribeToReportCommentPushNotifications.ts b/src/libs/Notification/PushNotification/subscribeToReportCommentPushNotifications.ts
index 9767210b3479..070f83e19e0f 100644
--- a/src/libs/Notification/PushNotification/subscribeToReportCommentPushNotifications.ts
+++ b/src/libs/Notification/PushNotification/subscribeToReportCommentPushNotifications.ts
@@ -1,12 +1,28 @@
import Onyx from 'react-native-onyx';
import Log from '@libs/Log';
import Navigation from '@libs/Navigation/Navigation';
+import getPolicyMemberAccountIDs from '@libs/PolicyMembersUtils';
+import {extractPolicyIDFromPath} from '@libs/PolicyUtils';
+import {doesReportBelongToWorkspace, getReport} from '@libs/ReportUtils';
import Visibility from '@libs/Visibility';
import * as Modal from '@userActions/Modal';
+import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
+import {isEmptyObject} from '@src/types/utils/EmptyObject';
import backgroundRefresh from './backgroundRefresh';
import PushNotification from './index';
+let lastVisitedPath: string | undefined;
+Onyx.connect({
+ key: ONYXKEYS.LAST_VISITED_PATH,
+ callback: (value) => {
+ if (!value) {
+ return;
+ }
+ lastVisitedPath = value;
+ },
+});
+
/**
* Setup reportComment push notification callbacks.
*/
@@ -23,6 +39,12 @@ export default function subscribeToReportCommentPushNotifications() {
Log.warn('[PushNotification] This push notification has no reportID');
}
+ const policyID = lastVisitedPath && extractPolicyIDFromPath(lastVisitedPath);
+ const report = getReport(reportID.toString());
+ const policyMembersAccountIDs = policyID ? getPolicyMemberAccountIDs(policyID) : [];
+
+ const reportBelongsToWorkspace = policyID && !isEmptyObject(report) && doesReportBelongToWorkspace(report, policyMembersAccountIDs, policyID);
+
Log.info('[PushNotification] onSelected() - called', false, {reportID, reportActionID});
Navigation.isNavigationReady()
.then(Navigation.waitForProtectedRoutes)
@@ -36,6 +58,9 @@ export default function subscribeToReportCommentPushNotifications() {
}
Log.info('[PushNotification] onSelected() - Navigation is ready. Navigating...', false, {reportID, reportActionID});
+ if (!reportBelongsToWorkspace) {
+ Navigation.navigateWithSwitchPolicyID({route: ROUTES.HOME});
+ }
Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(String(reportID)));
} catch (error) {
let errorMessage = String(error);
diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts
index 71d5626a4bed..a71ec36a8bb2 100644
--- a/src/libs/OptionsListUtils.ts
+++ b/src/libs/OptionsListUtils.ts
@@ -10,7 +10,21 @@ import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
import CONST from '@src/CONST';
import type {TranslationPaths} from '@src/languages/types';
import ONYXKEYS from '@src/ONYXKEYS';
-import type {Beta, Login, PersonalDetails, PersonalDetailsList, Policy, PolicyCategories, Report, ReportAction, ReportActions, Transaction, TransactionViolation} from '@src/types/onyx';
+import type {
+ Beta,
+ Login,
+ PersonalDetails,
+ PersonalDetailsList,
+ Policy,
+ PolicyCategories,
+ PolicyCategory,
+ PolicyTag,
+ Report,
+ ReportAction,
+ ReportActions,
+ Transaction,
+ TransactionViolation,
+} from '@src/types/onyx';
import type {Participant} from '@src/types/onyx/IOU';
import type * as OnyxCommon from '@src/types/onyx/OnyxCommon';
import type {PolicyTaxRate, PolicyTaxRates} from '@src/types/onyx/PolicyTaxRates';
@@ -52,6 +66,7 @@ type PayeePersonalDetails = {
descriptiveText: string;
login: string;
accountID: number;
+ keyForList: string;
};
type CategorySection = {
@@ -550,6 +565,8 @@ function getLastMessageTextForReport(report: OnyxEntry, lastActorDetails
lastMessageTextFromReport = ReportUtils.getReimbursementDeQueuedActionMessage(report);
} else if (ReportActionUtils.isDeletedParentAction(lastReportAction) && ReportUtils.isChatReport(report)) {
lastMessageTextFromReport = ReportUtils.getDeletedParentActionMessageForChatReport(lastReportAction);
+ } else if (ReportActionUtils.isPendingRemove(lastReportAction) && ReportActionUtils.isThreadParentMessage(lastReportAction, report?.reportID ?? '')) {
+ lastMessageTextFromReport = Localize.translateLocal('parentReportAction.hiddenMessage');
} else if (ReportUtils.isReportMessageAttachment({text: report?.lastMessageText ?? '', html: report?.lastMessageHtml, translationKey: report?.lastMessageTranslationKey, type: ''})) {
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
lastMessageTextFromReport = `[${Localize.translateLocal((report?.lastMessageTranslationKey || 'common.attachment') as TranslationPaths)}]`;
@@ -779,8 +796,8 @@ function getEnabledCategoriesCount(options: PolicyCategories): number {
/**
* Verifies that there is at least one enabled option
*/
-function hasEnabledOptions(options: PolicyCategories): boolean {
- return Object.values(options).some((option) => option.enabled);
+function hasEnabledOptions(options: PolicyCategory[] | PolicyTag[]): boolean {
+ return options.some((option) => option.enabled);
}
/**
@@ -1721,6 +1738,7 @@ function getIOUConfirmationOptionsFromPayeePersonalDetail(personalDetail: Person
descriptiveText: amountText,
login: personalDetail.login ?? '',
accountID: personalDetail.accountID,
+ keyForList: String(personalDetail.accountID),
};
}
@@ -1923,14 +1941,15 @@ function formatSectionsFromSearchTerm(
selectedOptions: ReportUtils.OptionData[],
filteredRecentReports: ReportUtils.OptionData[],
filteredPersonalDetails: PersonalDetails[],
+ maxOptionsSelected: boolean,
+ indexOffset = 0,
personalDetails: OnyxEntry = {},
shouldGetOptionDetails = false,
- indexOffset = 0,
): SectionForSearchTerm {
- // We show the selected participants at the top of the list when there is no search term
+ // We show the selected participants at the top of the list when there is no search term or maximum number of participants has already been selected
// However, if there is a search term we remove the selected participants from the top of the list unless they are part of the search results
// This clears up space on mobile views, where if you create a group with 4+ people you can't see the selected participants and the search results at the same time
- if (searchTerm === '') {
+ if (searchTerm === '' || maxOptionsSelected) {
return {
section: {
title: undefined,
diff --git a/src/libs/PolicyMembersUtils.ts b/src/libs/PolicyMembersUtils.ts
new file mode 100644
index 000000000000..4376de150f17
--- /dev/null
+++ b/src/libs/PolicyMembersUtils.ts
@@ -0,0 +1,25 @@
+import type {OnyxCollection} from 'react-native-onyx';
+import Onyx from 'react-native-onyx';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {PolicyMembers} from '@src/types/onyx';
+import {getCurrentUserAccountID} from './actions/Report';
+import {getPolicyMembersByIdWithoutCurrentUser} from './PolicyUtils';
+
+let policyMembers: OnyxCollection;
+Onyx.connect({
+ key: ONYXKEYS.COLLECTION.POLICY_MEMBERS,
+ waitForCollectionCallback: true,
+ callback: (value) => (policyMembers = value),
+});
+
+function getPolicyMemberAccountIDs(policyID?: string) {
+ if (!policyID) {
+ return [];
+ }
+
+ const currentUserAccountID = getCurrentUserAccountID();
+
+ return getPolicyMembersByIdWithoutCurrentUser(policyMembers, policyID, currentUserAccountID);
+}
+
+export default getPolicyMemberAccountIDs;
diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts
index b8ed62f93082..b6ee4ab3a353 100644
--- a/src/libs/PolicyUtils.ts
+++ b/src/libs/PolicyUtils.ts
@@ -90,10 +90,7 @@ function getPolicyBrickRoadIndicatorStatus(policy: OnyxEntry, policyMemb
*/
function shouldShowPolicy(policy: OnyxEntry, isOffline: boolean): boolean {
return (
- !!policy &&
- policy?.isPolicyExpenseChatEnabled &&
- policy?.role === CONST.POLICY.ROLE.ADMIN &&
- (isOffline || policy?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || Object.keys(policy.errors ?? {}).length > 0)
+ !!policy && policy?.isPolicyExpenseChatEnabled && (isOffline || policy?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || Object.keys(policy.errors ?? {}).length > 0)
);
}
@@ -210,6 +207,22 @@ function isPaidGroupPolicy(policy: OnyxEntry): boolean {
return policy?.type === CONST.POLICY.TYPE.TEAM || policy?.type === CONST.POLICY.TYPE.CORPORATE;
}
+function extractPolicyIDFromPath(path: string) {
+ return path.match(CONST.REGEX.POLICY_ID_FROM_PATH)?.[1];
+}
+
+function getPathWithoutPolicyID(path: string) {
+ return path.replace(CONST.REGEX.PATH_WITHOUT_POLICY_ID, '/');
+}
+
+function getPolicyMembersByIdWithoutCurrentUser(policyMembers: OnyxCollection, currentPolicyID?: string, currentUserAccountID?: number) {
+ return policyMembers
+ ? Object.keys(policyMembers[`${ONYXKEYS.COLLECTION.POLICY_MEMBERS}${currentPolicyID}`] ?? {})
+ .map((policyMemberAccountID) => Number(policyMemberAccountID))
+ .filter((policyMemberAccountID) => policyMemberAccountID !== currentUserAccountID)
+ : [];
+}
+
export {
getActivePolicies,
hasPolicyMemberError,
@@ -232,4 +245,7 @@ export {
isPendingDeletePolicy,
isPolicyMember,
isPaidGroupPolicy,
+ extractPolicyIDFromPath,
+ getPathWithoutPolicyID,
+ getPolicyMembersByIdWithoutCurrentUser,
};
diff --git a/src/libs/ReceiptUtils.ts b/src/libs/ReceiptUtils.ts
index 70d2d588d229..948d8094d551 100644
--- a/src/libs/ReceiptUtils.ts
+++ b/src/libs/ReceiptUtils.ts
@@ -1,6 +1,7 @@
import Str from 'expensify-common/lib/str';
import _ from 'lodash';
import type {ImageSourcePropType} from 'react-native';
+import type {OnyxEntry} from 'react-native-onyx';
import ReceiptDoc from '@assets/images/receipt-doc.png';
import ReceiptGeneric from '@assets/images/receipt-generic.png';
import ReceiptHTML from '@assets/images/receipt-html.png';
@@ -16,6 +17,7 @@ type ThumbnailAndImageURI = {
thumbnail: ImageSourcePropType | string | null;
transaction?: Transaction;
isLocalFile?: boolean;
+ filename?: string;
};
type FileNameAndExtension = {
@@ -30,14 +32,13 @@ type FileNameAndExtension = {
* @param receiptPath
* @param receiptFileName
*/
-function getThumbnailAndImageURIs(transaction: Transaction, receiptPath: string | null = null, receiptFileName: string | null = null): ThumbnailAndImageURI {
+function getThumbnailAndImageURIs(transaction: OnyxEntry, receiptPath: string | null = null, receiptFileName: string | null = null): ThumbnailAndImageURI {
if (Object.hasOwn(transaction?.pendingFields ?? {}, 'waypoints')) {
return {thumbnail: null, image: ReceiptGeneric, isLocalFile: true};
}
-
// URI to image, i.e. blob:new.expensify.com/9ef3a018-4067-47c6-b29f-5f1bd35f213d or expensify.com/receipts/w_e616108497ef940b7210ec6beb5a462d01a878f4.jpg
// If there're errors, we need to display them in preview. We can store many files in errors, but we just need to get the last one
- const errors = _.findLast(transaction.errors) as ReceiptError | undefined;
+ const errors = _.findLast(transaction?.errors) as ReceiptError | undefined;
const path = errors?.source ?? transaction?.receipt?.source ?? receiptPath ?? '';
// filename of uploaded image or last part of remote URI
const filename = errors?.filename ?? transaction?.filename ?? receiptFileName ?? '';
@@ -45,16 +46,16 @@ function getThumbnailAndImageURIs(transaction: Transaction, receiptPath: string
const hasEReceipt = transaction?.hasEReceipt;
if (hasEReceipt) {
- return {thumbnail: null, image: ROUTES.ERECEIPT.getRoute(transaction.transactionID), transaction};
+ return {thumbnail: null, image: ROUTES.ERECEIPT.getRoute(transaction.transactionID), transaction, filename};
}
// For local files, we won't have a thumbnail yet
if (isReceiptImage && (path.startsWith('blob:') || path.startsWith('file:'))) {
- return {thumbnail: null, image: path, isLocalFile: true};
+ return {thumbnail: null, image: path, isLocalFile: true, filename};
}
if (isReceiptImage) {
- return {thumbnail: `${path}.1024.jpg`, image: path};
+ return {thumbnail: `${path}.1024.jpg`, image: path, filename};
}
const {fileExtension} = FileUtils.splitExtensionFromFileName(filename) as FileNameAndExtension;
@@ -72,7 +73,7 @@ function getThumbnailAndImageURIs(transaction: Transaction, receiptPath: string
}
const isLocalFile = typeof path === 'number' || path.startsWith('blob:') || path.startsWith('file:') || path.startsWith('/');
- return {thumbnail: image, image: path, isLocalFile};
+ return {thumbnail: image, image: path, isLocalFile, filename};
}
// eslint-disable-next-line import/prefer-default-export
diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts
index 453d69e3041c..6e7cd0cdd999 100644
--- a/src/libs/ReportActionsUtils.ts
+++ b/src/libs/ReportActionsUtils.ts
@@ -39,6 +39,8 @@ type MemberChangeMessageRoomReferenceElement = {
type MemberChangeMessageElement = MessageTextElement | MemberChangeMessageUserMentionElement | MemberChangeMessageRoomReferenceElement;
+const policyChangeActionsSet = new Set(Object.values(CONST.REPORT.ACTIONS.TYPE.POLICYCHANGELOG));
+
const allReports: OnyxCollection = {};
Onyx.connect({
key: ONYXKEYS.COLLECTION.REPORT,
@@ -368,7 +370,7 @@ function shouldReportActionBeVisible(reportAction: OnyxEntry, key:
return false;
}
- if (isPendingRemove(reportAction)) {
+ if (isPendingRemove(reportAction) && !reportAction.childVisibleActionCount) {
return false;
}
@@ -411,33 +413,25 @@ function shouldReportActionBeVisibleAsLastAction(reportAction: OnyxEntry {
const reportActions = Object.values(fastMerge(allReportActions?.[reportID] ?? {}, actionsToMerge, true));
const visibleReportActions = Object.values(reportActions ?? {}).filter((action) => shouldReportActionBeVisibleAsLastAction(action));
@@ -494,7 +488,7 @@ function getSortedReportActionsForDisplay(reportActions: ReportActions | null, s
const filteredReportActions = Object.entries(reportActions ?? {})
.filter(([key, reportAction]) => shouldReportActionBeVisible(reportAction, key))
.map((entry) => entry[1]);
- const baseURLAdjustedReportActions = filteredReportActions.map((reportAction) => replaceBaseURL(reportAction));
+ const baseURLAdjustedReportActions = filteredReportActions.map((reportAction) => replaceBaseURLInPolicyChangeLogAction(reportAction));
return getSortedReportActions(baseURLAdjustedReportActions, true, shouldMarkTheFirstItemAsNewest);
}
@@ -806,6 +800,32 @@ function hasRequestFromCurrentAccount(reportID: string, currentAccountID: number
return reportActions.some((action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.IOU && action.actorAccountID === currentAccountID);
}
+/**
+ * @private
+ */
+function isReportActionUnread(reportAction: OnyxEntry, lastReadTime: string) {
+ if (!lastReadTime) {
+ return !isCreatedAction(reportAction);
+ }
+
+ return Boolean(reportAction && lastReadTime && reportAction.created && lastReadTime < reportAction.created);
+}
+
+/**
+ * Check whether the current report action of the report is unread or not
+ *
+ */
+function isCurrentActionUnread(report: Report | EmptyObject, reportAction: ReportAction): boolean {
+ const lastReadTime = report.lastReadTime ?? '';
+ const sortedReportActions = getSortedReportActions(Object.values(getAllReportActions(report.reportID)));
+ const currentActionIndex = sortedReportActions.findIndex((action) => action.reportActionID === reportAction.reportActionID);
+ if (currentActionIndex === -1) {
+ return false;
+ }
+ const prevReportAction = sortedReportActions[currentActionIndex - 1];
+ return isReportActionUnread(reportAction, lastReadTime) && (!prevReportAction || !isReportActionUnread(prevReportAction, lastReadTime));
+}
+
export {
extractLinksFromMessageHtml,
getAllReportActions,
@@ -855,6 +875,7 @@ export {
getMemberChangeMessageFragment,
getMemberChangeMessagePlainText,
isReimbursementDeQueuedAction,
+ isCurrentActionUnread,
};
export type {LastVisibleMessage};
diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts
index 3d21911c83e8..568ce49ff961 100644
--- a/src/libs/ReportUtils.ts
+++ b/src/libs/ReportUtils.ts
@@ -355,7 +355,7 @@ type OptionData = {
text?: string;
alternateText?: string | null;
allReportErrors?: Errors;
- brickRoadIndicator?: typeof CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR | '' | null;
+ brickRoadIndicator?: ValueOf | '' | null;
tooltipText?: string | null;
alternateTextMaxLines?: number;
boldStyle?: boolean;
@@ -402,6 +402,18 @@ type OnyxDataTaskAssigneeChat = {
optimisticChatCreatedReportAction?: OptimisticCreatedReportAction;
};
+type Ancestor = {
+ report: Report;
+ reportAction: ReportAction;
+ shouldDisplayNewMarker: boolean;
+ shouldHideThreadDividerLine: boolean;
+};
+
+type AncestorIDs = {
+ reportIDs: string[];
+ reportActionsIDs: string[];
+};
+
let currentUserEmail: string | undefined;
let currentUserAccountID: number | undefined;
let isAnonymousUser = false;
@@ -895,7 +907,7 @@ function isConciergeChatReport(report: OnyxEntry): boolean {
* Checks if the supplied report belongs to workspace based on the provided params. If the report's policyID is _FAKE_ or has no value, it means this report is a DM.
* In this case report and workspace members must be compared to determine whether the report belongs to the workspace.
*/
-function doesReportBelongToWorkspace(report: Report, policyID: string, policyMemberAccountIDs: number[]) {
+function doesReportBelongToWorkspace(report: Report, policyMemberAccountIDs: number[], policyID?: string) {
return (
isConciergeChatReport(report) || (report.policyID === CONST.POLICY.ID_FAKE || !report.policyID ? hasParticipantInArray(report, policyMemberAccountIDs) : report.policyID === policyID)
);
@@ -904,8 +916,8 @@ function doesReportBelongToWorkspace(report: Report, policyID: string, policyMem
/**
* Given an array of reports, return them filtered by a policyID and policyMemberAccountIDs.
*/
-function filterReportsByPolicyIdAndMemberAccountIDs(reports: Report[], policyID = '', policyMemberAccountIDs: number[] = []) {
- return reports.filter((report) => !!report && doesReportBelongToWorkspace(report, policyID, policyMemberAccountIDs));
+function filterReportsByPolicyIDAndMemberAccountIDs(reports: Report[], policyMemberAccountIDs: number[] = [], policyID?: string) {
+ return reports.filter((report) => !!report && doesReportBelongToWorkspace(report, policyMemberAccountIDs, policyID));
}
/**
@@ -1004,7 +1016,7 @@ function findLastAccessedReport(
let reportsValues = Object.values(reports ?? {}) as Report[];
if (!!policyID || policyMemberAccountIDs.length > 0) {
- reportsValues = filterReportsByPolicyIdAndMemberAccountIDs(reportsValues, policyID, policyMemberAccountIDs);
+ reportsValues = filterReportsByPolicyIDAndMemberAccountIDs(reportsValues, policyMemberAccountIDs, policyID);
}
let sortedReports = sortReportsByLastRead(reportsValues, reportMetadata);
@@ -2425,7 +2437,8 @@ function getReportName(report: OnyxEntry, policy: OnyxEntry = nu
}
if (
parentReportAction?.message?.[0]?.moderationDecision?.decision === CONST.MODERATION.MODERATOR_DECISION_PENDING_HIDE ||
- parentReportAction?.message?.[0]?.moderationDecision?.decision === CONST.MODERATION.MODERATOR_DECISION_HIDDEN
+ parentReportAction?.message?.[0]?.moderationDecision?.decision === CONST.MODERATION.MODERATOR_DECISION_HIDDEN ||
+ parentReportAction?.message?.[0]?.moderationDecision?.decision === CONST.MODERATION.MODERATOR_DECISION_PENDING_REMOVE
) {
return Localize.translateLocal('parentReportAction.hiddenMessage');
}
@@ -3616,10 +3629,6 @@ function canSeeDefaultRoom(report: OnyxEntry, policies: OnyxCollection, policies: OnyxCollection