diff --git a/.github/workflows/cherryPick.yml b/.github/workflows/cherryPick.yml index e6da6fff1446..43f3c64554bc 100644 --- a/.github/workflows/cherryPick.yml +++ b/.github/workflows/cherryPick.yml @@ -41,6 +41,7 @@ jobs: token: ${{ secrets.OS_BOTIFY_TOKEN }} - name: Set up git for OSBotify + id: setupGitForOSBotify uses: Expensify/App/.github/actions/composite/setupGitForOSBotifyApp@8c19d6da4a3d7ce3b15c9cd89a802187d208ecab with: GPG_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} @@ -119,7 +120,7 @@ jobs: **Important:** There may be conflicts that GitHub is not able to detect, so please _carefully_ review this pull request before approving." gh pr edit --add-assignee "${{ github.actor }},${{ steps.getCPMergeCommit.outputs.MERGE_ACTOR }}" env: - GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} + GITHUB_TOKEN: ${{ steps.setupGitForOSBotify.outputs.OS_BOTIFY_API_TOKEN }} - name: "Announces a CP failure in the #announce Slack room" uses: 8398a7/action-slack@v3 diff --git a/.github/workflows/finishReleaseCycle.yml b/.github/workflows/finishReleaseCycle.yml index 4fe6249edacc..f8b68786aaab 100644 --- a/.github/workflows/finishReleaseCycle.yml +++ b/.github/workflows/finishReleaseCycle.yml @@ -34,13 +34,13 @@ jobs: echo "IS_DEPLOYER=false" >> "$GITHUB_OUTPUT" fi env: - GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} + GITHUB_TOKEN: ${{ steps.setupGitForOSBotify.outputs.OS_BOTIFY_API_TOKEN }} - name: Reopen and comment on issue (not a team member) if: ${{ !fromJSON(steps.isDeployer.outputs.IS_DEPLOYER) }} uses: Expensify/App/.github/actions/javascript/reopenIssueWithComment@main with: - GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} + GITHUB_TOKEN: ${{ steps.setupGitForOSBotify.outputs.OS_BOTIFY_API_TOKEN }} ISSUE_NUMBER: ${{ github.event.issue.number }} COMMENT: | Sorry, only members of @Expensify/Mobile-Deployers can close deploy checklists. @@ -51,14 +51,14 @@ jobs: id: checkDeployBlockers uses: Expensify/App/.github/actions/javascript/checkDeployBlockers@main with: - GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} + GITHUB_TOKEN: ${{ steps.setupGitForOSBotify.outputs.OS_BOTIFY_API_TOKEN }} ISSUE_NUMBER: ${{ github.event.issue.number }} - name: Reopen and comment on issue (has blockers) if: ${{ fromJSON(steps.isDeployer.outputs.IS_DEPLOYER) && fromJSON(steps.checkDeployBlockers.outputs.HAS_DEPLOY_BLOCKERS || 'false') }} uses: Expensify/App/.github/actions/javascript/reopenIssueWithComment@main with: - GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} + GITHUB_TOKEN: ${{ steps.setupGitForOSBotify.outputs.OS_BOTIFY_API_TOKEN }} ISSUE_NUMBER: ${{ github.event.issue.number }} COMMENT: | This issue either has unchecked items or has not yet been marked with the `:shipit:` emoji of approval. diff --git a/android/app/build.gradle b/android/app/build.gradle index b07c66308609..c7974572e665 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -90,8 +90,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001039505 - versionName "1.3.95-5" + versionCode 1001039600 + versionName "1.3.96-0" } flavorDimensions "default" diff --git a/contributingGuides/CONTRIBUTING.md b/contributingGuides/CONTRIBUTING.md index 24e0d1878237..6e02cae677bb 100644 --- a/contributingGuides/CONTRIBUTING.md +++ b/contributingGuides/CONTRIBUTING.md @@ -148,7 +148,7 @@ Additionally if you want to discuss an idea with the open source community witho - If you have made a change to your pull request and are ready for another review, leave a comment that says "Updated" on the pull request itself. - Please keep the conversation in GitHub, and do not ping individual reviewers in Slack or Upwork to get their attention. - Pull Request reviews can sometimes take a few days. If your pull request has not been addressed after four days please let us know via the #expensify-open-source Slack channel. -- On occasion, our engineers will need to focus on a feature release and choose to place a hold on the review of your PR. Depending on the hold length, our team will decide if a bonus will be applied to the job. +- On occasion, our engineers will need to focus on a feature release and choose to place a hold on the review of your PR. #### Important note about JavaScript Style - Read our official [JavaScript and React style guide](https://github.com/Expensify/App/blob/main/contributingGuides/STYLE.md). Please refer to our Style Guide before asking for a review. diff --git a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/Personal-Cards.md b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/Personal-Cards.md new file mode 100644 index 000000000000..71edcdeba00d --- /dev/null +++ b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/Personal-Cards.md @@ -0,0 +1,5 @@ +--- +title: Personal Cards +description: Connect your credit card directly to Expensify to easily track your personal finances. +--- +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/Personal-Credit-Cards.md b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/Personal-Credit-Cards.md deleted file mode 100644 index f89729b69586..000000000000 --- a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/Personal-Credit-Cards.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Personal Credit Cards -description: Personal Credit Cards ---- -## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/expense-and-report-features/The-Expenses-Page.md b/docs/articles/expensify-classic/expense-and-report-features/The-Expenses-Page.md index f30dde9efc3d..42a8a914e5bc 100644 --- a/docs/articles/expensify-classic/expense-and-report-features/The-Expenses-Page.md +++ b/docs/articles/expensify-classic/expense-and-report-features/The-Expenses-Page.md @@ -1,5 +1,73 @@ --- title: The Expenses Page -description: The Expenses Page +description: Details on Expenses Page filters --- -## Resource Coming Soon! +# Overview + +The Expenses page allows you to see all of your personal expenses. If you are an admin, you can view all submitter’s expenses on the Expensify page. The Expenses page can be filtered in several ways to give you spending visibility, find expenses to submit and export to a spreadsheet (CSV). + +## Expense filters +Here are the available filters you can use on the Expenses Page: + +- **Date Range:** Find expenses within a specific time frame. +- **Merchant Name:** Search for expenses from a particular merchant. (Partial search terms also work if you need clarification on the exact name match.) +- **Workspace:** Locate specific Group/Individual Workspace expenses. +- **Categories:** Group expenses by category or identify those without a category. +- **Tags:** Filter expenses with specific tags. +- **Submitters:** Narrow expenses by submitter (employee or vendor). +- **Personal Expenses:** Find all expenses yet to be included in a report. A Workspace admin can see these expenses once they are on a Processing, Approved, or Reimbursed report. +- **Open:** Display expenses on reports that still need to be submitted (not submitted). +- **Processing, Approved, Reimbursed:** See expenses on reports at various stages – processing, approved, or reimbursed. +- **Closed:** View expenses on closed reports (not submitted for approval). + +Here's how to make the most of these filters: + +1. Log into your web account +2. Go to the **Expenses** page +3. At the top of the page, click on **Show Filters** +4. Adjust the filters to match your specific needs + +Note, you might notice that not all expense filters are always visible. They adapt based on the data you're currently filtering and persist from the last time you logged in. For instance, you won't see the deleted filter if there are no **Deleted** expenses to filter out. + +If you are not seeing what you expected, you may have too many filters applied. Click **Reset** at the top to clear your filters. + + +# How to add an expense to a report from the Expenses Page +The submitter (and their copilot) can add expenses to a report from the Expenses page. + +Note, when expenses aren’t on a report, they are **personal expenses**. So you’ll want to make sure you haven’t filtered out **personal expenses** expenses, or you won’t be able to see them. + +1. Find the expense you want to add. (Hint: Use the filters to sort expenses by the desired date range if it is not a recent expense.) +2. Then, select the expense you want to add to a report. You can click Select All to select multiple expenses. +3. Click **Add to Report** in the upper right corner, and choose either an existing report or create a new one. + +# How to code expenses from the Expenses Page +To code expenses from the Expenses page, do the following: + +1. Look for the **Tag**, **Category**, and **Description** columns on the **Expenses** page. +2. Click on the relevant field for a specific expense and add or update the **Category**, **Tag**, or **Description**. + +Note, you can also open up individual expenses by clicking on them to see a detailed look, but coding the expenses from the Expense list is even faster and more convenient! + +# How to export expenses to a CSV file or spreadsheet +If you want to export multiple expenses, run through the below steps: +Select the expenses you want to export by checking the box to the left of each expense. +Then, click **Export To** in the upper right corner of the page, and choose our default CSV format or create your own custom CSV template. + + +# FAQ + +## Can I use the filters and analytics features on the mobile app? +The various features on the Expenses Page are only available while logged into your web account. + +## As a Workspace admin, what submitter expenses can you see? +A Workspace admin can see Processing, Approved, and Reimbursed expenses as long as they were submitted on the workspace that you are an admin. + +If employees submit expense reports on a workspace where you are not an admin, you will not have visibility into those expenses. Additionally, if an expense is left unreported, a workspace admin will not be able to see that expense until it’s been added to a report. + +A Workspace admin can edit the tags and categories on an expense, but if they want to edit the amount, date, or merchant name, the expense will need to be in a Processing state or rejected back to the submitter for changes. +We have more about company card expense reconciliation in this support article. + +## Can I edit multiple expenses at once? +Yes! Select the expenses you want to edit and click **Edit Multiple**. + diff --git a/docs/articles/expensify-classic/expensify-card/Expensify-Card-Perks.md b/docs/articles/expensify-classic/expensify-card/Expensify-Card-Perks.md index 5c9761b7ff1d..4c216faffc18 100644 --- a/docs/articles/expensify-classic/expensify-card/Expensify-Card-Perks.md +++ b/docs/articles/expensify-classic/expensify-card/Expensify-Card-Perks.md @@ -5,40 +5,7 @@ description: Get the most out of your Expensify Card with exclusive perks! # Overview -The Expensify Card is packed with perks, both native to our Card program and through exclusive discounts with partnering solutions. The Expensify Card’s primary perks include: -- Access to our premiere Expensify Lounge (with more locations coming soon) -- Swipe to Win, where every swipe has a chance to win fun personalized gifts for you and your closest friends and family members -- And unbeatable cash back incentive with each swipe -Below, we’ll cover all of our exclusive offers in more detail and how to claim discounts with our partners. - -# Expensify Card Perks - -## Access to the Expensify Lounge -Our [world-class lounge](https://use.expensify.com/lounge) is now open for Expensify members and guests to enjoy! - -We invite you to visit our sleek San Francisco lounge, where sweeping city views provide the perfect backdrop for a morning coffee to start your day. - -Enjoy complimentary cocktails and snacks in a vibrant atmosphere with blazing-fast WiFi. Whether you want a place to focus on work, socialize with other members, or simply kick back and relax – our lounge is ready and waiting to welcome you. - -You can sign up for free [here](https://use.expensify.com) if you’re not an Expensify member. If you have any questions, reach out to concierge@expensify.com and [check this out](https://use.expensify.com/lounge) for more info. - -## Swipe to Win -Swipe to Win is a new [Expensify Card](https://use.expensify.com/company-credit-card) perk that gives cardholders the chance to send a gift to a friend, family member, or essential worker on the frontlines! - -Winners can choose to _Send a Smile_ or _Send a Laugh_. To start, we’re offering one gift per option: - -- **Send A Smile:** Champagne by Expensify -- **Send a Laugh:** Jenga Set - -**How to Participate** -It’s easy! Once you have an Expensify Card, you just need to start using it. With each swipe, you're automatically entered to win and have a 1 in 250 chance of getting a prize! - -**How will I know if I’ve won?** -Winners will be notified immediately via the Expensify app, and receive additional instructions on how to choose and send their desired gift. - -If you don't have Expensify notifications turned on yet, here are some helpful guides: -- [Apple Notification Preferences](https://support.apple.com/en-us/HT201925) -- [Android Notification Preferences](https://community.expensify.com/home/leaving?allowTrusted=1&target=https%3A%2F%2Fsupport.google.com%2Fandroid%2Fanswer%2F9079661%3Fhl%3Den) +The Expensify Card is packed with perks, both native to our Card program and through exclusive discounts with partnering solutions. Below, we’ll cover all of our exclusive offers in more detail and how to claim discounts with our partners. # Partner Specific Perks @@ -222,26 +189,3 @@ Stripe Atlas helps removes obstacles typically associated with starting a busine **How to redeem:** Sign up with your Expensify Card. -# FAQ - -## Where is the Expensify Lounge? -The Expensify Lounge is located on the 16th floor of 88 Kearny Street in San Francisco, California, 94108. This is currently our only lounge location, but keep an eye out for more work lounges popping up soon! - -## When is the Expensify Lounge open? -The lounge is open 8 a.m. to 6 p.m. from Monday through Friday, except for national holidays. Capacity is limited, and we are admitting loungers on a first-come, first-served basis, so make sure to get there early! - -## Who can use the lounge workplace? -Customers with an Expensify subscription can use Expensify’s lounge workplace, and any partner who has completed [ExpensifyApproved! University!](https://university.expensify.com/users/sign_in?next=%2Fdashboard) - - - - -# FAQ -This section covers the useful but not as vital information, it should capture commonly queried elements which do not organically form part of the About or How-to sections. - -- What's idiosyncratic or potentially confusing about this feature? -- Is there anything unique about how this feature relates to billing/activity? -- If this feature is released, are there any common confusions that can't be solved by improvements to the product itself? -- Similarly, if this feature hasn't been released, can you predict and pre-empt any potential confusion? -- Is there any general troubleshooting for this feature? - - Note: troubleshooting should generally go in the FAQ, but if there is extensive troubleshooting, such as with integrations, that will be housed in a separate page, stored with and linked from the main page for that feature. diff --git a/docs/articles/expensify-classic/getting-started/Policy-Admins.md b/docs/articles/expensify-classic/getting-started/Policy-Admins.md deleted file mode 100644 index 484350f101a5..000000000000 --- a/docs/articles/expensify-classic/getting-started/Policy-Admins.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Policy Admins -description: Policy Admins ---- -## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-Small-To-Medium-Sized-Businesses.md b/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-Small-To-Medium-Sized-Businesses.md index d933e66cc2d1..3ad3110bf09b 100644 --- a/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-Small-To-Medium-Sized-Businesses.md +++ b/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-Small-To-Medium-Sized-Businesses.md @@ -6,7 +6,7 @@ redirect_from: articles/playbooks/Expensify-Playbook-for-Small-to-Medium-Sized-B # Overview This guide provides practical tips and recommendations for small businesses with 100 to 250 employees to effectively use Expensify to improve spend visibility, facilitate employee reimbursements, and reduce the risk of fraudulent expenses. -- See our [US-based VC-Backed Startups](https://help.expensify.com/articles/playbooks/Expensify-Playbook-for-US-based-VC-Backed-Startups) if you are more concerned with top-line revenue growth +- See our [US-based VC-Backed Startups](https://help.expensify.com/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-US-Based-VC-Backed-Startups) if you are more concerned with top-line revenue growth # Who you are As a small to medium-sized business owner, your main aim is to achieve success and grow your business. To achieve your goals, it is crucial that you make worthwhile investments in both your workforce and your business processes. This means providing your employees with the resources they need to generate revenue effectively, while also adopting measures to guarantee that expenses are compliant. @@ -22,23 +22,23 @@ If you don't already have one, go to *[new.expensify.com](https://new.expensify. > **Robyn Gresham** > Senior Accounting Systems Manager at SunCommon -## Step 2: Create a Control Policy -There are three policy types, but for your small business needs we recommend the *Control Plan* for the following reasons: +## Step 2: Create a Control Workspace +There are three workspace types, but for your small business needs we recommend the *Control Plan* for the following reasons: - *The Control Plan* is designed for organizations with a high volume of employee expense submissions, who also rely on compliance controls - The ease of use and mobile-first design of the Control plan can increase employee adoption and participation, leading to better expense tracking and management. - The plan integrates with a variety of tools, including accounting software and payroll systems, providing a seamless and integrated experience - Accounting integrations include QuickBooks Online, Xero, NetSuite, and Sage Intacct, with indirect support from Microsoft Dynamics and any other accounting solution you work with -We recommend creating one single policy for your US entity. This allows you to centrally manage all employees in one “group” while enforcing compliance controls and syncing with your accounting package accordingly. +We recommend creating one single workspace for your US entity. This allows you to centrally manage all employees in one “group” while enforcing compliance controls and syncing with your accounting package accordingly. -To create your Control Policy: +To create your Control Workspace: -1. Go to *Settings > Policies* -2. Select *Group* and click the button that says *New Policy* +1. Go to *Settings > Workspace* +2. Select *Group* and click the button that says *New Workspace* 3. Click *Select* under Control -The Control Plan also gives you access to a dedicated Setup Specialist. You can find yours by looking at your policy's *#admins* room in *[new.expensify.com](https://new.expensify.com)*, and in your company’s policy settings in the *Overview* tab, where you can chat with them and schedule an onboarding call to walk through any setup questions. The Control Plan bundled with the Expensify Card is only *$9 per user per month* (not taking into account cash back your earn) when you commit annually. That’s a 75% discount off the unbundled price point if you choose to use a different Corporate Card (or no) provider. +The Control Plan also gives you access to a dedicated Setup Specialist. You can find yours by looking at your workspace's *#admins* room in *[new.expensify.com](https://new.expensify.com)*, and in your company’s workspace settings in the *Overview* tab, where you can chat with them and schedule an onboarding call to walk through any setup questions. The Control Plan bundled with the Expensify Card is only *$9 per user per month* (not taking into account cash back your earn) when you commit annually. That’s a 75% discount off the unbundled price point if you choose to use a different Corporate Card (or no) provider. ## Step 3: Connect your accounting system As a small to medium-sized business, it's important to maintain proper spend management to ensure the success and stability of your organization. This requires paying close attention to your expenses, streamlining your financial processes, and making sure that your financial information is accurate, compliant, and transparent. Include best practices such as: @@ -49,17 +49,17 @@ As a small to medium-sized business, it's important to maintain proper spend man You do this by synchronizing Expensify and your accounting package as follows: -1. Click *Settings > Policies* +1. Click *Settings > Workspace* 2. Navigate to the *Connections* tab 3. Select your accounting system 4. Follow the prompts to connect your accounting package Check out the links below for more information on how to connect to your accounting solution: -- *[QuickBooks Online](https://community.expensify.com/discussion/4833/how-to-connect-your-policy-to-quickbooks-online)* -- *[Xero](https://community.expensify.com/discussion/5282/how-to-connect-your-policy-to-xero)* -- *[NetSuite](https://community.expensify.com/discussion/5212/how-to-connect-your-policy-to-netsuite-token-based-authentication)* -- *[Sage Intacct](https://community.expensify.com/discussion/4777/how-to-connect-to-sage-intacct-user-based-permissions-expense-reports)* -- *[Other Accounting System](https://community.expensify.com/discussion/5271/how-to-set-up-an-indirect-accounting-integration) +- *[QuickBooks Online](https://help.expensify.com/articles/expensify-classic/integrations/accounting-integrations/QuickBooks-Online#gsc.tab=0)* +- *[Xero](https://help.expensify.com/articles/expensify-classic/integrations/accounting-integrations/Xero#gsc.tab=0)* +- *[NetSuite](https://help.expensify.com/articles/expensify-classic/integrations/accounting-integrations/NetSuite#gsc.tab=0)* +- *[Sage Intacct](https://help.expensify.com/articles/expensify-classic/integrations/accounting-integrations/Sage-Intacct#gsc.tab=0)* +- *[Other Accounting System](https://help.expensify.com/articles/expensify-classic/integrations/accounting-integrations/Indirect-Accounting-Integrations#gsc.tab=0) *“Employees really appreciate how easy it is to use, and the fact that the reimbursement drops right into their bank account. Since most employees are submitting expenses from their phones, the ease of use of the app is critical.”* @@ -82,15 +82,15 @@ Head over to the *Categories* tab to set compliance controls on your newly impor Tags in Expensify often relate to departments, projects/customers, classes, and so on. And in some cases they are *required* to be selected on every transactions. And in others, something like *departments* is a static field, meaning we could set it as an employee default and not enforce the tag selection with each expense. *Make Tags Required* -In the tags tab in your policy settings, you’ll notice the option to enable the “Required” field. This makes it so any time an employee doesn’t assign a tag to an expense, we’ll flag a violation on it and notify both the employee and the approver. +In the tags tab in your workspace settings, you’ll notice the option to enable the “Required” field. This makes it so any time an employee doesn’t assign a tag to an expense, we’ll flag a violation on it and notify both the employee and the approver. - *Note:* In general, we take prior selection into account, so anytime you select a tag in Expensify, we’ll pre-populate that same field for any subsequent expense. It’s completely interchangeable, and there for convenience. *Set Tags as an Employee Default* -Separately, if your policy is connected to NetSuite or Sage Intacct, you can set departments, for example, as an employee default. All that means is we’ll apply the department (for example) that’s assigned to the employee record in your accounting package and apply that to every exported transaction, eliminating the need for the employee to have to manually select a department for each expense. +Separately, if your workspace is connected to NetSuite or Sage Intacct, you can set departments, for example, as an employee default. All that means is we’ll apply the department (for example) that’s assigned to the employee record in your accounting package and apply that to every exported transaction, eliminating the need for the employee to have to manually select a department for each expense. ## Step 6: Set rules for all expenses regardless of categorization -In the Expenses tab in your group Control policy, you’ll notice a *Violations* section designed to enforce top-level compliance controls that apply to every expense, for every employee in your policy. We recommend the following confiuration: +In the Expenses tab in your group Control workspace, you’ll notice a *Violations* section designed to enforce top-level compliance controls that apply to every expense, for every employee in your workspace. We recommend the following confiuration: *Max Expense Age: 90 days (or leave it blank)* This will enable Expensify to catch employee reimbursement requests that are far too outdated for reimbursement, and present them as a violations. If you’d prefer a different time window, you can edit it accordingly @@ -106,17 +106,17 @@ Receipts are important, and in most cases you prefer an itemized receipt. Howeve At this point, you’ve set enough compliance controls around categorical spend and general expenses for all employees, such that you can put trust in our solution to audit all expenses up front so you don’t have to. Next, let’s dive into how we can comfortably take on more automation, while relying on compliance controls to capture bad behavior (or better yet, instill best practices in our employees). ## Step 7: Set up scheduled submit -For an efficient company, we recommend setting up [Scheduled Submit](https://community.expensify.com/discussion/4476/how-to-enable-scheduled-submit-for-a-group-policy) on a *Daily* frequency: +For an efficient company, we recommend setting up [Scheduled Submit](https://help.expensify.com/articles/expensify-classic/policy-and-domain-settings/reports/Scheduled-Submit#gsc.tab=0) on a *Daily* frequency: -- Click *Settings > Policies* -- From here, select your group collect policy -- Within your policy settings, select the *Reports* tab +- Click *Settings > Workspace* +- From here, select your group collect workspace +- Within your workspace settings, select the *Reports* tab - You’ll notice *Scheduled Submit* is located directly under *Report Basics* - Choose *Daily* -Between Expensify's SmartScan technology, automatic categorization, and [DoubleCheck](https://community.expensify.com/discussion/5738/deep-dive-how-does-concierge-receipt-audit-work) features, your employees shouldn't need to do anything more than swipe their Expensify Card or take a photo of their receipt. +Between Expensify's SmartScan technology, automatic categorization, and [DoubleCheck](https://help.expensify.com/articles/expensify-classic/manage-employees-and-report-approvals/Approving-Reports) features, your employees shouldn't need to do anything more than swipe their Expensify Card or take a photo of their receipt. -Expenses with violations will stay behind for the employee to fix, while expenses that are “in-policy” will move into an approver’s queue to mitigate any potential for delays. Scheduled Submit will ensure all expenses are submitted automatically for approval. +Expenses with violations will stay behind for the employee to fix, while expenses that are “in-workspace” will move into an approver’s queue to mitigate any potential for delays. Scheduled Submit will ensure all expenses are submitted automatically for approval. ![Scheduled submit](https://help.expensify.com/assets/images/playbook-scheduled-submit.png){:width="100%"} @@ -147,10 +147,10 @@ You only need to do this once: you are fully set up for not only reimbursing exp ## Step 9: Invite employees and set an approval workflow *Select an Approval Mode* -We recommend you select *Advanced Approval* as your Approval Mode to set up a middle-management layer of a approval. If you have a single layer of approval, we recommend selecting [Submit & Approve](https://community.expensify.com/discussion/5643/deep-dive-submit-and-approve). But if *Advanced Approval* if your jam, keep reading! +We recommend you select *Advanced Approval* as your Approval Mode to set up a middle-management layer of approval. If you have a single layer of approval, we recommend selecting [Submit & Approve](https://help.expensify.com/articles/expensify-classic/manage-employees-and-report-approvals/Approval-Workflows#gsc.tab=0). But if *Advanced Approval* is your jam, keep reading! *Import your employees in bulk via CSV* -Given the amount of employees you have, it’s best you import employees in bulk via CSV. You can learn more about using a CSV file to bulk upload employees with *Advanced Approval [here](https://community.expensify.com/discussion/5735/deep-dive-the-ins-and-outs-of-advanced-approval)* +Given the amount of employees you have, it’s best you import employees in bulk via CSV. You can learn more about using a CSV file to bulk upload employees with *Advanced Approval [here](https://help.expensify.com/articles/expensify-classic/manage-employees-and-report-approvals/Approval-Workflows#gsc.tab=0)* ![Bulk import your employees](https://help.expensify.com/assets/images/playbook-impoort-employees.png){:width="100%"} @@ -162,8 +162,8 @@ In this case we recommend setting *Manually approve all expenses over: $0* ## Step 10: Configure Auto-Approval Knowing you have all the control you need to review reports, we recommend configuring auto-approval for *all reports*. Why? Because you’ve already put reports through an entire approval workflow, and manually triggering reimbursement is an unnecessary action at this stage. -1. Navigate to *Settings > Policies > Group > [Policy Name] > Reimbursement* -2. Set your *Manual Reimbursement threshold to $20,0000* +1. Navigate to *Settings > Workspace > Group > [Workspace Name] > Reimbursement* +2. Set your *Manual Reimbursement threshold to $20,000* ## Step 11: Enable Domains and set up your corporate card feed for employees Expensify is optimized to work with corporate cards from all banks – or even better, use our own perfectly integrated *[Expensify Card](https://use.expensify.com/company-credit-card)*. The first step for connecting to any bank you use for corporate cards, and the Expensify Card is to validate your company’s domain in Domain settings. @@ -191,7 +191,7 @@ As mentioned above, we’ll be able to pull in transactions as they post (daily) Expensify provides a corporate card with the following features: - Up to 2% cash back (up to 4% in your first 3 months!) -- [SmartLimits](https://community.expensify.com/discussion/4851/deep-dive-what-are-unapproved-expense-limits#latest) to control what each individual cardholder can spend +- [SmartLimits](https://help.expensify.com/articles/expensify-classic/expensify-card/Card-Settings) to control what each individual cardholder can spend - A stable, unbreakable real-time connection (third-party bank feeds can run into connectivity issues) - Receipt compliance - informing notifications (eg. add a receipt!) for users *as soon as the card is swiped* - A 50% discount on the price of all Expensify plans @@ -202,8 +202,8 @@ The Expensify Card is recommended as the most efficient way to manage your compa Here’s how to enable it: -1. There are *two ways* you can [apply for the Expensify Card](https://community.expensify.com/discussion/4874/how-to-apply-for-the-expensify-card) - - *Via your Inbox* +1. There are *two ways* you can [apply for the Expensify Card](https://help.expensify.com/articles/expensify-classic/expensify-card/Set-Up-the-Card-for-Your-Company) + - *Via your tasks on the Home page* - *Via Domain Settings* - Go to Settings > Domain > Company Cards > Enable Expensify Card 2. Assign the cards to your employees 3. Set *SmartLimits*: @@ -212,14 +212,14 @@ Here’s how to enable it: Once the Expensify Cards have been assigned, each employee will be prompted to enter their mailing address so they can receive their physical card. In the meantime, a virtual card will be ready to use immediately. -If you have an accounting system we directly integrate with, check out how we take automation a step further with [Continuous Reconciliation](https://community.expensify.com/discussion/7335/faq-what-is-the-expensify-card-auto-reconciliation-process). We’ll create an Expensify Card clearing and liability account for you. Each time settlement occurs, we’ll take the total amount of your purchases and create a journal entry that credits the settlement account and debits the liability account - saving you hours of manual reconciliation work at the end of your statement period. +If you have an accounting system we directly integrate with, check out how we take automation a step further with [Continuous Reconciliation](https://help.expensify.com/articles/expensify-classic/expensify-card/Auto-Reconciliation). We’ll create an Expensify Card clearing and liability account for you. Each time settlement occurs, we’ll take the total amount of your purchases and create a journal entry that credits the settlement account and debits the liability account - saving you hours of manual reconciliation work at the end of your statement period. ## Step 12: Set up Bill Pay and Invoicing As a small business, managing bills and invoices can be a complex and time-consuming task. Whether you receive bills from vendors or need to invoice clients, it's important to have a solution that makes the process simple, efficient, and cost-effective. Here are some of the key benefits of using Expensify for bill payments and invoicing: - Flexible payment options: Expensify allows you to pay your bills via ACH, credit card, or check, so you can choose the option that works best for you (US businesses only). -- Free, No Fees: The bill pay and invoicing features come included with every policy and workspace, so you won't need to pay any additional fees. +- Free, No Fees: The bill pay and invoicing features come included with every workspace and workspace, so you won't need to pay any additional fees. - Integration with your business bank account: With your business bank account verified, you can easily link your finances to receive payment from customers when invoices are paid. Let’s first chat through how Bill Pay works @@ -244,7 +244,7 @@ Reports, invoices, and bills are largely the same, in theory, just with differen 2. Add all of the expenses/transactions tied to the Invoice 3. Enter the recipient’s email address, a memo if needed, and a due date for when it needs to get paid, and click *Send* -You’ll notice it’s a slightly different flow from creating a Bill. Here, you are adding the transactions tied to the Invoice, and establishing a due date for when it needs to get paid. If you need to apply any markups, you can do so from your policy settings under the Invoices tab. Your customers can pay their invoice in Expensify via ACH, or Check, or Credit Card. +You’ll notice it’s a slightly different flow from creating a Bill. Here, you are adding the transactions tied to the Invoice, and establishing a due date for when it needs to get paid. If you need to apply any markups, you can do so from your workspace settings under the Invoices tab. Your customers can pay their invoice in Expensify via ACH, or Check, or Credit Card. ## Step 13: Run monthly, quarterly and annual reporting At this stage, reporting is important and given that Expensify is the primary point of entry for all employee spend, we make reporting visually appealing and wildly customizable. @@ -266,7 +266,7 @@ Our pricing model is unique in the sense that you are in full control of your bi To set your subscription, head to: -1. Settings > Policies +1. Settings > Workspace 2. Select *Group* 3. Scroll down to *Subscription* 4. Select *Annual Subscription* @@ -281,4 +281,4 @@ Now that we’ve gone through all of the steps for setting up your account, let 4. Click *Accept Terms* # You’re all set! -Congrats, you are all set up! If you need any assistance with anything mentioned above or would like to understand other features available in Expensify, reach out to your Setup Specialist directly in *[new.expensify.com](https://new.expensify.com)*. Don’t have one yet? Create a Control Policy, and we’ll automatically assign a dedicated Setup Specialist to you. +Congrats, you are all set up! If you need any assistance with anything mentioned above or would like to understand other features available in Expensify, reach out to your Setup Specialist directly in *[new.expensify.com](https://new.expensify.com)*. Don’t have one yet? Create a Control Workspace, and we’ll automatically assign a dedicated Setup Specialist to you. diff --git a/docs/articles/expensify-classic/workspace-and-domain-settings/Reimbursement.md b/docs/articles/expensify-classic/workspace-and-domain-settings/Reimbursement.md index 3ee1c8656b4b..a1916465fca8 100644 --- a/docs/articles/expensify-classic/workspace-and-domain-settings/Reimbursement.md +++ b/docs/articles/expensify-classic/workspace-and-domain-settings/Reimbursement.md @@ -1,5 +1,47 @@ --- -title: Coming Soon -description: Coming Soon +title: Reimbursement +description: Enable reimbursement and reimburse expense reports --- -## Resource Coming Soon! + + +# Overview +Reimbursement in Expensify is quick, easy, and completely free. Let Expensify do the tedious work for you by taking advantage of features to automate employee reimbursement. + +# How to Enable Reimbursement +There are several options for reimbursing employees in Expensify. The options available will depend on which country your business bank account is domiciled in. + +## Direct Reimbursement + +Direct reimbursement is available to companies who have a verified US bank account and are reimbursing employees within the US. To use direct reimbursement, you must have a US business bank account verified in Expensify. + +A Workspace admin can enable direct reimbursement via **Settings > Workspaces > Workspace Name > Reimbursement > Direct**. + +**Additional features under Reimbursement > Direct:** + - Select a **default reimburser** for the Workspace from the dropdown menu. The default reimburser is the person who will receive notifications to reimburse reports in Expensify. You’ll be able to choose among all Workspace Admins who have access to the business bank account. + - Set a **default withdrawal account** for the Workspace. This will set a default bank account that report reimbursements are withdrawn from. + - Set a **manual reimbursement threshold** to automate reimbursement. Reports whose total falls under the manual reimbursement threshhold will be reimbursed automatocally upon final approval; reports whose total falls above the threshhold will need to be reimbursed manually by the default reimburser. + +Expensify also offers direct global reimbursement to some companies with verified bank accounts in USD, GBP, EUR and AUD who are reimbursing employees internationally. For more information about Global Reimbursement, see LINK + +## Indirect Reimbursement + +Indirect reimbursement is available to all companies in Expensify and no bank account is required. Indirect reimbursement indicates that the report will be reimbursed outside of Expensify. + +A Workspace admin can enanble indirect reimbursement via **Settings > Workspaces > Workspace Name > Reimbursement > Indirect**. + +**Additional features under Reimbursement > Indirect:** +If you reimburse through a seperate system or through payroll, Expensify can collect and export employee bank account details for you. Just reach out to your Account Manager or concierge@expensify.com for us to add the Reimbursement Details Export format to the account. + +# FAQ + +## How do I export employee bank account details once the Reimbursement Details Export format is added to my account? + +Employee bank account details can be exported from the Reports page by selecting the relevant Approved reports and then clicking **Export to > Reimbursement Details Export**. + +## Is it possible to change the name of a verified business bank account in Expensify? + +Bank account names can be updated via **Settings > Accounts > Payments** and clicking the pencil icon next to the bank account name. + +## What is the benefit of setting a default reimburser? + +The main benefit of being defined as the "reimburser" in the Workspace settings is that this user will receive notifications on their Home page alerting them when reports need to be reimbursed. diff --git a/docs/assets/images/ExpensifyHelp_CloseAccount_Mobile.png b/docs/assets/images/ExpensifyHelp_CloseAccount_Mobile.png new file mode 100644 index 000000000000..2e11b7eb1f49 Binary files /dev/null and b/docs/assets/images/ExpensifyHelp_CloseAccount_Mobile.png differ diff --git a/docs/assets/images/attendee-tracking.png b/docs/assets/images/attendee-tracking.png index 1888851b2a13..66ab22b6efe7 100644 Binary files a/docs/assets/images/attendee-tracking.png and b/docs/assets/images/attendee-tracking.png differ diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 1966f3862d59..94d2986fd111 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.3.95 + 1.3.96 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 1.3.95.5 + 1.3.96.0 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 387687a2beaa..9478336965cf 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.3.95 + 1.3.96 CFBundleSignature ???? CFBundleVersion - 1.3.95.5 + 1.3.96.0 diff --git a/metro.config.js b/metro.config.js index 9d61e3e3f3bd..8917e60fc758 100644 --- a/metro.config.js +++ b/metro.config.js @@ -6,13 +6,15 @@ require('dotenv').config(); const defaultConfig = getDefaultConfig(__dirname); -const isUsingMockAPI = process.env.E2E_TESTING === 'true'; +const isE2ETesting = process.env.E2E_TESTING === 'true'; -if (isUsingMockAPI) { +if (isE2ETesting) { // eslint-disable-next-line no-console console.log('⚠️⚠️⚠️⚠️ Using mock API ⚠️⚠️⚠️⚠️'); } +const e2eSourceExts = ['e2e.js', 'e2e.ts']; + /** * Metro configuration * https://facebook.github.io/metro/docs/configuration @@ -22,10 +24,11 @@ if (isUsingMockAPI) { const config = { resolver: { assetExts: [..._.filter(defaultAssetExts, (ext) => ext !== 'svg'), 'lottie'], - sourceExts: [...defaultSourceExts, 'jsx', 'svg'], + // When we run the e2e tests we want files that have the extension e2e.js to be resolved as source files + sourceExts: [...(isE2ETesting ? e2eSourceExts : []), ...defaultSourceExts, 'jsx', 'svg'], resolveRequest: (context, moduleName, platform) => { const resolution = context.resolveRequest(context, moduleName, platform); - if (isUsingMockAPI && moduleName.includes('/API')) { + if (isE2ETesting && moduleName.includes('/API')) { const originalPath = resolution.filePath; const mockPath = originalPath.replace('src/libs/API.ts', 'src/libs/E2E/API.mock.js').replace('/src/libs/API.js/', 'src/libs/E2E/API.mock.js'); // eslint-disable-next-line no-console diff --git a/package-lock.json b/package-lock.json index bc29544e9a86..d80dd7504a28 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.3.95-5", + "version": "1.3.96-0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.3.95-5", + "version": "1.3.96-0", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index ded77f236d05..51f56d773e05 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.3.95-5", + "version": "1.3.96-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.", @@ -49,8 +49,9 @@ "analyze-packages": "ANALYZE_BUNDLE=true webpack --config config/webpack/webpack.common.js --env envFile=.env.production", "symbolicate:android": "npx metro-symbolicate android/app/build/generated/sourcemaps/react/release/index.android.bundle.map", "symbolicate:ios": "npx metro-symbolicate main.jsbundle.map", - "test:e2e:main": "node tests/e2e/testRunner.js --development --branch main --skipCheckout", - "test:e2e:delta": "node tests/e2e/testRunner.js --development --branch main --label delta --skipCheckout", + "test:e2e:dev": "node tests/e2e/testRunner.js --development --skipCheckout --config ./config.dev.js --buildMode skip --skipInstallDeps", + "test:e2e:main": "node tests/e2e/testRunner.js --development --skipCheckout", + "test:e2e:delta": "node tests/e2e/testRunner.js --development --label delta --skipCheckout --skipInstallDeps", "test:e2e:compare": "node tests/e2e/merge.js", "gh-actions-unused-styles": "./.github/scripts/findUnusedKeys.sh", "workflow-test": "./workflow_tests/scripts/runWorkflowTests.sh", diff --git a/src/CONST.ts b/src/CONST.ts index 9e7c1f007335..de902931ffd8 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -50,6 +50,9 @@ const CONST = { // An arbitrary size, but the same minimum as in the PHP layer MIN_SIZE: 240, + + // Allowed extensions for receipts + ALLOWED_RECEIPT_EXTENSIONS: ['jpg', 'jpeg', 'gif', 'png', 'pdf', 'htm', 'html', 'text', 'rtf', 'doc', 'tif', 'tiff', 'msword', 'zip', 'xml', 'message'], }, AUTO_AUTH_STATE: { diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 11c2318672d8..8de77ff678a5 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -420,7 +420,7 @@ type OnyxValues = { [ONYXKEYS.FORMS.WELCOME_MESSAGE_FORM]: OnyxTypes.Form; [ONYXKEYS.FORMS.LEGAL_NAME_FORM]: OnyxTypes.Form; [ONYXKEYS.FORMS.WORKSPACE_INVITE_MESSAGE_FORM]: OnyxTypes.Form; - [ONYXKEYS.FORMS.DATE_OF_BIRTH_FORM]: OnyxTypes.Form; + [ONYXKEYS.FORMS.DATE_OF_BIRTH_FORM]: OnyxTypes.DateOfBirthForm; [ONYXKEYS.FORMS.HOME_ADDRESS_FORM]: OnyxTypes.Form; [ONYXKEYS.FORMS.NEW_ROOM_FORM]: OnyxTypes.Form; [ONYXKEYS.FORMS.ROOM_SETTINGS_FORM]: OnyxTypes.Form; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index bcc4685368cb..864e8934ad88 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -2,14 +2,11 @@ import {ValueOf} from 'type-fest'; import CONST from './CONST'; /** - * This is a file containing constants for all of the routes we want to be able to go to + * This is a file containing constants for all the routes we want to be able to go to */ /** - * This is a file containing constants for all of the routes we want to be able to go to - * Returns the URL with an encoded URI component for the backTo param which can be added to the end of URLs - * @param backTo - * @returns + * Builds a URL with an encoded URI component for the `backTo` param which can be added to the end of URLs */ function getUrlWithBackToParam(url: string, backTo?: string): string { const backToParam = backTo ? `${url.includes('?') ? '&' : '?'}backTo=${encodeURIComponent(backTo)}` : ''; @@ -111,7 +108,10 @@ export default { route: 'settings/profile/personal-details/address/country', getRoute: (country: string, backTo?: string) => getUrlWithBackToParam(`settings/profile/personal-details/address/country?country=${country}`, backTo), }, - SETTINGS_CONTACT_METHODS: 'settings/profile/contact-methods', + SETTINGS_CONTACT_METHODS: { + route: 'settings/profile/contact-methods', + getRoute: (backTo?: string) => getUrlWithBackToParam('settings/profile/contact-methods', backTo), + }, SETTINGS_CONTACT_METHOD_DETAILS: { route: 'settings/profile/contact-methods/:contactMethod/details', getRoute: (contactMethod: string) => `settings/profile/contact-methods/${encodeURIComponent(contactMethod)}/details`, diff --git a/src/components/Attachments/AttachmentCarousel/CarouselButtons.js b/src/components/Attachments/AttachmentCarousel/CarouselButtons.js index 9bef889e61a1..f11bbcc9b187 100644 --- a/src/components/Attachments/AttachmentCarousel/CarouselButtons.js +++ b/src/components/Attachments/AttachmentCarousel/CarouselButtons.js @@ -82,5 +82,6 @@ function CarouselButtons({page, attachments, shouldShowArrows, onBack, onForward CarouselButtons.propTypes = propTypes; CarouselButtons.defaultProps = defaultProps; +CarouselButtons.displayName = 'CarouselButtons'; export default CarouselButtons; diff --git a/src/components/Attachments/AttachmentCarousel/CarouselItem.js b/src/components/Attachments/AttachmentCarousel/CarouselItem.js index 2d271aa6d4c4..53a8606c927f 100644 --- a/src/components/Attachments/AttachmentCarousel/CarouselItem.js +++ b/src/components/Attachments/AttachmentCarousel/CarouselItem.js @@ -116,5 +116,6 @@ function CarouselItem({item, isFocused, onPress}) { CarouselItem.propTypes = propTypes; CarouselItem.defaultProps = defaultProps; +CarouselItem.displayName = 'CarouselItem'; export default CarouselItem; diff --git a/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPage.js b/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPage.js index 2ded34829a08..7a083d71b591 100644 --- a/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPage.js +++ b/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPage.js @@ -181,7 +181,9 @@ function AttachmentCarouselPage({source, isAuthTokenRequired, isActive: initialI ); } + AttachmentCarouselPage.propTypes = pagePropTypes; AttachmentCarouselPage.defaultProps = defaultProps; +AttachmentCarouselPage.displayName = 'AttachmentCarouselPage'; export default AttachmentCarouselPage; diff --git a/src/components/Attachments/AttachmentCarousel/Pager/ImageTransformer.js b/src/components/Attachments/AttachmentCarousel/Pager/ImageTransformer.js index 5bf8b79dae77..0839462d4f23 100644 --- a/src/components/Attachments/AttachmentCarousel/Pager/ImageTransformer.js +++ b/src/components/Attachments/AttachmentCarousel/Pager/ImageTransformer.js @@ -574,5 +574,6 @@ function ImageTransformer({imageWidth, imageHeight, imageScaleX, imageScaleY, sc } ImageTransformer.propTypes = imageTransformerPropTypes; ImageTransformer.defaultProps = imageTransformerDefaultProps; +ImageTransformer.displayName = 'ImageTransformer'; export default ImageTransformer; diff --git a/src/components/Attachments/AttachmentCarousel/Pager/ImageWrapper.js b/src/components/Attachments/AttachmentCarousel/Pager/ImageWrapper.js index 10f2ae94340a..3a27d80c5509 100644 --- a/src/components/Attachments/AttachmentCarousel/Pager/ImageWrapper.js +++ b/src/components/Attachments/AttachmentCarousel/Pager/ImageWrapper.js @@ -1,4 +1,3 @@ -/* eslint-disable es/no-optional-chaining */ import PropTypes from 'prop-types'; import React from 'react'; import {StyleSheet} from 'react-native'; @@ -19,6 +18,8 @@ function ImageWrapper({children}) { ); } + ImageWrapper.propTypes = imageWrapperPropTypes; +ImageWrapper.displayName = 'ImageWrapper'; export default ImageWrapper; diff --git a/src/components/Attachments/AttachmentCarousel/Pager/index.js b/src/components/Attachments/AttachmentCarousel/Pager/index.js index e4659caf24f0..59fd7596f0ad 100644 --- a/src/components/Attachments/AttachmentCarousel/Pager/index.js +++ b/src/components/Attachments/AttachmentCarousel/Pager/index.js @@ -1,4 +1,3 @@ -/* eslint-disable es/no-optional-chaining */ import PropTypes from 'prop-types'; import React, {useImperativeHandle, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; @@ -168,8 +167,10 @@ function AttachmentCarouselPager({ ); } + AttachmentCarouselPager.propTypes = pagerPropTypes; AttachmentCarouselPager.defaultProps = pagerDefaultProps; +AttachmentCarouselPager.displayName = 'AttachmentCarouselPager'; const AttachmentCarouselPagerWithRef = React.forwardRef((props, ref) => ( ); } + AttachmentCarousel.propTypes = propTypes; AttachmentCarousel.defaultProps = defaultProps; +AttachmentCarousel.displayName = 'AttachmentCarousel'; export default compose( withOnyx({ diff --git a/src/components/Attachments/AttachmentCarousel/index.native.js b/src/components/Attachments/AttachmentCarousel/index.native.js index 7088a5c7057c..b86c9b1c786e 100644 --- a/src/components/Attachments/AttachmentCarousel/index.native.js +++ b/src/components/Attachments/AttachmentCarousel/index.native.js @@ -169,6 +169,7 @@ function AttachmentCarousel({report, reportActions, source, onNavigate, onClose, } AttachmentCarousel.propTypes = propTypes; AttachmentCarousel.defaultProps = defaultProps; +AttachmentCarousel.displayName = 'AttachmentCarousel'; export default compose( withOnyx({ diff --git a/src/components/Attachments/AttachmentView/AttachmentViewImage/index.js b/src/components/Attachments/AttachmentView/AttachmentViewImage/index.js index 23049915a8d9..307dbe8e9ddb 100755 --- a/src/components/Attachments/AttachmentView/AttachmentViewImage/index.js +++ b/src/components/Attachments/AttachmentView/AttachmentViewImage/index.js @@ -39,5 +39,6 @@ function AttachmentViewImage({source, file, isAuthTokenRequired, loadComplete, o AttachmentViewImage.propTypes = propTypes; AttachmentViewImage.defaultProps = attachmentViewImageDefaultProps; +AttachmentViewImage.displayName = 'AttachmentViewImage'; export default compose(memo, withLocalize)(AttachmentViewImage); diff --git a/src/components/Attachments/AttachmentView/AttachmentViewImage/index.native.js b/src/components/Attachments/AttachmentView/AttachmentViewImage/index.native.js index faf2f21c133d..cb1190fa1fdd 100755 --- a/src/components/Attachments/AttachmentView/AttachmentViewImage/index.native.js +++ b/src/components/Attachments/AttachmentView/AttachmentViewImage/index.native.js @@ -47,5 +47,6 @@ function AttachmentViewImage({source, file, isAuthTokenRequired, isFocused, isUs AttachmentViewImage.propTypes = propTypes; AttachmentViewImage.defaultProps = attachmentViewImageDefaultProps; +AttachmentViewImage.displayName = 'AttachmentViewImage'; export default compose(memo, withLocalize)(AttachmentViewImage); diff --git a/src/components/Avatar.js b/src/components/Avatar.js index 546387031643..4b8ddd45aa95 100644 --- a/src/components/Avatar.js +++ b/src/components/Avatar.js @@ -119,6 +119,9 @@ function Avatar(props) { ); } + Avatar.defaultProps = defaultProps; Avatar.propTypes = propTypes; +Avatar.displayName = 'Avatar'; + export default Avatar; diff --git a/src/components/Composer/index.js b/src/components/Composer/index.js index f8045eb87f9f..924705e0fd39 100755 --- a/src/components/Composer/index.js +++ b/src/components/Composer/index.js @@ -490,6 +490,7 @@ function Composer({ Composer.propTypes = propTypes; Composer.defaultProps = defaultProps; +Composer.displayName = 'Composer'; const ComposerWithRef = React.forwardRef((props, ref) => ( {`${props.translate('common.youAppearToBeOffline')} ${props.translate('common.thisFeatureRequiresInternet')}`} diff --git a/src/components/CountrySelector.js b/src/components/CountrySelector.js index 93a90dcf6be9..c2426c5b7b0b 100644 --- a/src/components/CountrySelector.js +++ b/src/components/CountrySelector.js @@ -53,7 +53,7 @@ function CountrySelector({errorText, value: countryCode, onInputChange, forwarde descriptionTextStyle={countryTitleDescStyle} description={translate('common.country')} onPress={() => { - const activeRoute = Navigation.getActiveRoute().replace(/\?.*/, ''); + const activeRoute = Navigation.getActiveRouteWithoutParams(); Navigation.navigate(ROUTES.SETTINGS_PERSONAL_DETAILS_ADDRESS_COUNTRY.getRoute(countryCode, activeRoute)); }} /> diff --git a/src/components/DatePicker/index.android.js b/src/components/DatePicker/index.android.js index 561fc700b6a5..3b2a6ec3e650 100644 --- a/src/components/DatePicker/index.android.js +++ b/src/components/DatePicker/index.android.js @@ -61,7 +61,7 @@ function DatePicker({value, defaultValue, label, placeholder, errorText, contain /> {isPickerVisible && ( { @@ -129,7 +147,7 @@ function FormWrapper(props) { focusInput.focus(); } }} - containerStyles={[styles.mh0, styles.mt5, styles.flex1]} + containerStyles={[styles.mh0, styles.mt5, styles.flex1, ...submitButtonStyles]} enabledWhenOffline={enabledWhenOffline} isSubmitActionDangerous={isSubmitActionDangerous} disablePressOnEnter @@ -151,6 +169,7 @@ function FormWrapper(props) { isSubmitButtonVisible, onSubmit, style, + submitButtonStyles, submitButtonText, ], ); diff --git a/src/components/Image/index.js b/src/components/Image/index.js index c2800511ff45..ef1a69e19c12 100644 --- a/src/components/Image/index.js +++ b/src/components/Image/index.js @@ -69,4 +69,5 @@ const ImageWithOnyx = React.memo( imagePropsAreEqual, ); ImageWithOnyx.resizeMode = RESIZE_MODES; + export default ImageWithOnyx; diff --git a/src/components/Image/index.native.js b/src/components/Image/index.native.js index 52ac503081e6..cf5320392d1b 100644 --- a/src/components/Image/index.native.js +++ b/src/components/Image/index.native.js @@ -59,4 +59,5 @@ const ImageWithOnyx = withOnyx({ })(Image); ImageWithOnyx.resizeMode = RESIZE_MODES; ImageWithOnyx.resolveDimensions = resolveDimensions; + export default ImageWithOnyx; diff --git a/src/components/ImageWithSizeCalculation.js b/src/components/ImageWithSizeCalculation.tsx similarity index 66% rename from src/components/ImageWithSizeCalculation.js rename to src/components/ImageWithSizeCalculation.tsx index 5db78e0c1276..fe4cc4a01bc0 100644 --- a/src/components/ImageWithSizeCalculation.js +++ b/src/components/ImageWithSizeCalculation.tsx @@ -1,31 +1,27 @@ -import PropTypes from 'prop-types'; +import delay from 'lodash/delay'; import React, {useEffect, useRef, useState} from 'react'; -import {View} from 'react-native'; -import _ from 'underscore'; +import {StyleProp, View, ViewStyle} from 'react-native'; +import {OnLoadEvent} from 'react-native-fast-image'; import Log from '@libs/Log'; import styles from '@styles/styles'; import FullscreenLoadingIndicator from './FullscreenLoadingIndicator'; import Image from './Image'; +import RESIZE_MODES from './Image/resizeModes'; -const propTypes = { +type OnMeasure = (args: {width: number; height: number}) => void; + +type ImageWithSizeCalculationProps = { /** Url for image to display */ - url: PropTypes.string.isRequired, + url: string; /** Any additional styles to apply */ - // eslint-disable-next-line react/forbid-prop-types - style: PropTypes.any, + style?: StyleProp; /** Callback fired when the image has been measured. */ - onMeasure: PropTypes.func, + onMeasure: OnMeasure; /** Whether the image requires an authToken */ - isAuthTokenRequired: PropTypes.bool, -}; - -const defaultProps = { - style: {}, - onMeasure: () => {}, - isAuthTokenRequired: false, + isAuthTokenRequired: boolean; }; /** @@ -33,23 +29,19 @@ const defaultProps = { * Image size must be provided by parent via width and height props. Useful for * performing some calculation on a network image after fetching dimensions so * it can be appropriately resized. - * - * @param {Object} props - * @returns {React.Component} - * */ -function ImageWithSizeCalculation(props) { - const isLoadedRef = useRef(null); +function ImageWithSizeCalculation({url, style, onMeasure, isAuthTokenRequired}: ImageWithSizeCalculationProps) { + const isLoadedRef = useRef(null); const [isImageCached, setIsImageCached] = useState(true); const [isLoading, setIsLoading] = useState(false); const onError = () => { - Log.hmmm('Unable to fetch image to calculate size', {url: props.url}); + Log.hmmm('Unable to fetch image to calculate size', {url}); }; - const imageLoadedSuccessfully = (event) => { + const imageLoadedSuccessfully = (event: OnLoadEvent) => { isLoadedRef.current = true; - props.onMeasure({ + onMeasure({ width: event.nativeEvent.width, height: event.nativeEvent.height, }); @@ -57,10 +49,10 @@ function ImageWithSizeCalculation(props) { /** Delay the loader to detect whether the image is being loaded from the cache or the internet. */ useEffect(() => { - if (isLoadedRef.current || !isLoading) { + if (isLoadedRef.current ?? !isLoading) { return; } - const timeout = _.delay(() => { + const timeout = delay(() => { if (!isLoading || isLoadedRef.current) { return; } @@ -70,14 +62,14 @@ function ImageWithSizeCalculation(props) { }, [isLoading]); return ( - + { - if (isLoadedRef.current || isLoading) { + if (isLoadedRef.current ?? isLoading) { return; } setIsLoading(true); @@ -94,7 +86,5 @@ function ImageWithSizeCalculation(props) { ); } -ImageWithSizeCalculation.propTypes = propTypes; -ImageWithSizeCalculation.defaultProps = defaultProps; ImageWithSizeCalculation.displayName = 'ImageWithSizeCalculation'; export default React.memo(ImageWithSizeCalculation); diff --git a/src/components/InvertedFlatList/index.js b/src/components/InvertedFlatList/index.js index 14b781759904..7cfa72d9c712 100644 --- a/src/components/InvertedFlatList/index.js +++ b/src/components/InvertedFlatList/index.js @@ -130,6 +130,7 @@ InvertedFlatList.defaultProps = { contentContainerStyle: {}, onScroll: () => {}, }; +InvertedFlatList.displayName = 'InvertedFlatList'; const InvertedFlatListWithRef = forwardRef((props, ref) => ( item; + +function LHNOptionsList({ + style, + contentContainerStyles, + data, + onSelectRow, + optionMode, + shouldDisableFocusOptions, + reports, + reportActions, + policy, + preferredLocale, + personalDetails, + transactions, + draftComments, + currentReportID, +}) { /** * This function is used to compute the layout of any given item in our list. Since we know that each item will have the exact same height, this is a performance optimization * so that the heights can be determined before the options are rendered. Otherwise, the heights are determined when each option is rendering and it causes a lot of overhead on large @@ -45,14 +112,17 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio * * @returns {Object} */ - const getItemLayout = (itemData, index) => { - const optionHeight = optionMode === CONST.OPTION_MODE.COMPACT ? variables.optionRowHeightCompact : variables.optionRowHeight; - return { - length: optionHeight, - offset: index * optionHeight, - index, - }; - }; + const getItemLayout = useCallback( + (itemData, index) => { + const optionHeight = optionMode === CONST.OPTION_MODE.COMPACT ? variables.optionRowHeightCompact : variables.optionRowHeight; + return { + length: optionHeight, + offset: index * optionHeight, + index, + }; + }, + [optionMode], + ); /** * Function which renders a row in the list @@ -62,13 +132,38 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio * * @return {Component} */ - const renderItem = ({item}) => ( - + const renderItem = useCallback( + ({item: reportID}) => { + const itemFullReport = reports[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`] || {}; + const itemReportActions = reportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`]; + const itemParentReportActions = reportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${itemFullReport.parentReportID}`]; + const itemPolicy = policy[`${ONYXKEYS.COLLECTION.POLICY}${itemFullReport.policyID}`]; + const itemTransaction = `${ONYXKEYS.COLLECTION.TRANSACTION}${lodashGet( + itemParentReportActions, + [itemFullReport.parentReportActionID, 'originalMessage', 'IOUTransactionID'], + '', + )}`; + const itemComment = draftComments[`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`] || ''; + const participantPersonalDetailList = _.values(OptionsListUtils.getPersonalDetailsForAccountIDs(itemFullReport.participantAccountIDs, personalDetails)); + return ( + + ); + }, + [currentReportID, draftComments, onSelectRow, optionMode, personalDetails, policy, preferredLocale, reportActions, reports, shouldDisableFocusOptions, transactions], ); return ( @@ -80,7 +175,7 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio showsVerticalScrollIndicator={false} data={data} testID="lhn-options-list" - keyExtractor={(item) => item} + keyExtractor={keyExtractor} stickySectionHeadersEnabled={false} renderItem={renderItem} getItemLayout={getItemLayout} @@ -94,5 +189,31 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio LHNOptionsList.propTypes = propTypes; LHNOptionsList.defaultProps = defaultProps; +LHNOptionsList.displayName = 'LHNOptionsList'; -export default LHNOptionsList; +export default compose( + withCurrentReportID, + withOnyx({ + reports: { + key: ONYXKEYS.COLLECTION.REPORT, + }, + reportActions: { + key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, + }, + policy: { + key: ONYXKEYS.COLLECTION.POLICY, + }, + preferredLocale: { + key: ONYXKEYS.NVP_PREFERRED_LOCALE, + }, + personalDetails: { + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + }, + transactions: { + key: ONYXKEYS.COLLECTION.TRANSACTION, + }, + draftComments: { + key: ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT, + }, + }), +)(LHNOptionsList); diff --git a/src/components/LHNOptionsList/OptionRowLHNData.js b/src/components/LHNOptionsList/OptionRowLHNData.js index ebba2ffe0587..4db32ed8bd2a 100644 --- a/src/components/LHNOptionsList/OptionRowLHNData.js +++ b/src/components/LHNOptionsList/OptionRowLHNData.js @@ -1,19 +1,14 @@ import {deepEqual} from 'fast-equals'; -import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; import React, {useEffect, useMemo, useRef} from 'react'; -import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; import participantPropTypes from '@components/participantPropTypes'; -import compose from '@libs/compose'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import SidebarUtils from '@libs/SidebarUtils'; import * as TransactionUtils from '@libs/TransactionUtils'; -import * as UserUtils from '@libs/UserUtils'; import reportActionPropTypes from '@pages/home/report/reportActionPropTypes'; import * as Report from '@userActions/Report'; import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; import OptionRowLHN, {defaultProps as baseDefaultProps, propTypes as basePropTypes} from './OptionRowLHN'; const propTypes = { @@ -21,7 +16,7 @@ const propTypes = { isFocused: PropTypes.bool, /** List of users' personal details */ - personalDetails: PropTypes.objectOf(participantPropTypes), + personalDetails: PropTypes.arrayOf(participantPropTypes), /** The preferred language for the app */ preferredLocale: PropTypes.string, @@ -44,10 +39,8 @@ const propTypes = { parentReportActions: PropTypes.objectOf(PropTypes.shape(reportActionPropTypes)), /** The transaction from the parent report action */ - transaction: PropTypes.shape({ - /** The ID of the transaction */ - transactionID: PropTypes.string, - }), + transactionID: PropTypes.string, + ...basePropTypes, }; @@ -57,7 +50,7 @@ const defaultProps = { fullReport: {}, policy: {}, parentReportActions: {}, - transaction: {}, + transactionID: undefined, preferredLocale: CONST.LOCALES.DEFAULT, ...baseDefaultProps, }; @@ -78,11 +71,10 @@ function OptionRowLHNData({ policy, receiptTransactions, parentReportActions, - transaction, + transactionID, ...propsToForward }) { const reportID = propsToForward.reportID; - const parentReportAction = parentReportActions[fullReport.parentReportActionID]; const optionItemRef = useRef(); @@ -105,7 +97,7 @@ function OptionRowLHNData({ // Listen parentReportAction to update title of thread report when parentReportAction changed // Listen to transaction to update title of transaction report when transaction changed // eslint-disable-next-line react-hooks/exhaustive-deps - }, [fullReport, linkedTransaction, reportActions, personalDetails, preferredLocale, policy, parentReportAction, transaction]); + }, [fullReport, linkedTransaction, reportActions, personalDetails, preferredLocale, policy, parentReportAction, transactionID]); useEffect(() => { if (!optionItem || optionItem.hasDraftComment || !comment || comment.length <= 0 || isFocused) { @@ -129,30 +121,6 @@ OptionRowLHNData.propTypes = propTypes; OptionRowLHNData.defaultProps = defaultProps; OptionRowLHNData.displayName = 'OptionRowLHNData'; -/** - * @param {Object} [personalDetails] - * @returns {Object|undefined} - */ -const personalDetailsSelector = (personalDetails) => - _.reduce( - personalDetails, - (finalPersonalDetails, personalData, accountID) => { - // It's OK to do param-reassignment in _.reduce() because we absolutely know the starting state of finalPersonalDetails - // eslint-disable-next-line no-param-reassign - finalPersonalDetails[accountID] = { - accountID: Number(accountID), - login: personalData.login, - displayName: personalData.displayName, - firstName: personalData.firstName, - status: personalData.status, - avatar: UserUtils.getAvatar(personalData.avatar, personalData.accountID), - fallbackIcon: personalData.fallbackIcon, - }; - return finalPersonalDetails; - }, - {}, - ); - /** * This component is rendered in a list. * On scroll we want to avoid that a item re-renders @@ -160,48 +128,4 @@ const personalDetailsSelector = (personalDetails) => * Thats also why the React.memo is used on the outer component here, as we just * use it to prevent re-renders from parent re-renders. */ -export default React.memo( - compose( - withOnyx({ - comment: { - key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`, - }, - fullReport: { - key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, - }, - reportActions: { - key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, - canEvict: false, - }, - personalDetails: { - key: ONYXKEYS.PERSONAL_DETAILS_LIST, - selector: personalDetailsSelector, - }, - preferredLocale: { - key: ONYXKEYS.NVP_PREFERRED_LOCALE, - }, - }), - // eslint-disable-next-line rulesdir/no-multiple-onyx-in-file - withOnyx({ - parentReportActions: { - key: ({fullReport}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${fullReport.parentReportID}`, - canEvict: false, - }, - policy: { - key: ({fullReport}) => `${ONYXKEYS.COLLECTION.POLICY}${fullReport.policyID}`, - }, - // Ideally, we aim to access only the last transaction for the current report by listening to changes in reportActions. - // In some scenarios, a transaction might be created after reportActions have been modified. - // This can lead to situations where `lastTransaction` doesn't update and retains the previous value. - // However, performance overhead of this is minimized by using memos inside the component. - receiptTransactions: {key: ONYXKEYS.COLLECTION.TRANSACTION}, - }), - // eslint-disable-next-line rulesdir/no-multiple-onyx-in-file - withOnyx({ - transaction: { - key: ({fullReport, parentReportActions}) => - `${ONYXKEYS.COLLECTION.TRANSACTION}${lodashGet(parentReportActions, [fullReport.parentReportActionID, 'originalMessage', 'IOUTransactionID'], '')}`, - }, - }), - )(OptionRowLHNData), -); +export default React.memo(OptionRowLHNData); diff --git a/src/components/LHNOptionsList/OptionRowLHNDataWithFocus.js b/src/components/LHNOptionsList/OptionRowLHNDataWithFocus.js deleted file mode 100644 index 67e90bcbb0e0..000000000000 --- a/src/components/LHNOptionsList/OptionRowLHNDataWithFocus.js +++ /dev/null @@ -1,39 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import withCurrentReportID, {withCurrentReportIDDefaultProps, withCurrentReportIDPropTypes} from '@components/withCurrentReportID'; -import OptionRowLHNData from './OptionRowLHNData'; - -const propTypes = { - ...withCurrentReportIDPropTypes, - shouldDisableFocusOptions: PropTypes.bool, -}; - -const defaultProps = { - ...withCurrentReportIDDefaultProps, - shouldDisableFocusOptions: false, -}; - -/** - * Wrapper component for OptionRowLHNData that calculates isFocused prop based on currentReportID. - * This is extracted from OptionRowLHNData to prevent unnecessary re-renders when currentReportID changes. - * @returns {React.Component} OptionRowLHNData component with isFocused prop - */ -function OptionRowLHNDataWithFocus({currentReportID, shouldDisableFocusOptions, ...props}) { - // We only want to pass a boolean to the memoized component, - // instead of a changing number (so we prevent unnecessary re-renders). - const isFocused = !shouldDisableFocusOptions && currentReportID === props.reportID; - - return ( - - ); -} - -OptionRowLHNDataWithFocus.defaultProps = defaultProps; -OptionRowLHNDataWithFocus.propTypes = propTypes; -OptionRowLHNDataWithFocus.displayName = 'OptionRowLHNDataWithFocus'; - -export default withCurrentReportID(OptionRowLHNDataWithFocus); diff --git a/src/components/LinearGradient/index.js b/src/components/LinearGradient/index.js deleted file mode 100644 index 8270681641d0..000000000000 --- a/src/components/LinearGradient/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import LinearGradient from 'react-native-web-linear-gradient'; - -export default LinearGradient; diff --git a/src/components/LinearGradient/index.native.js b/src/components/LinearGradient/index.native.js deleted file mode 100644 index c8d5af2646b2..000000000000 --- a/src/components/LinearGradient/index.native.js +++ /dev/null @@ -1,3 +0,0 @@ -import LinearGradient from 'react-native-linear-gradient'; - -export default LinearGradient; diff --git a/src/components/LinearGradient/index.native.ts b/src/components/LinearGradient/index.native.ts new file mode 100644 index 000000000000..46bed24ebc10 --- /dev/null +++ b/src/components/LinearGradient/index.native.ts @@ -0,0 +1,6 @@ +import LinearGradientNative from 'react-native-linear-gradient'; +import LinearGradient from './types'; + +const LinearGradientImplementation: LinearGradient = LinearGradientNative; + +export default LinearGradientImplementation; diff --git a/src/components/LinearGradient/index.ts b/src/components/LinearGradient/index.ts new file mode 100644 index 000000000000..7246ccf2fb69 --- /dev/null +++ b/src/components/LinearGradient/index.ts @@ -0,0 +1,6 @@ +import LinearGradientWeb from 'react-native-web-linear-gradient'; +import LinearGradient from './types'; + +const LinearGradientImplementation: LinearGradient = LinearGradientWeb; + +export default LinearGradientImplementation; diff --git a/src/components/LinearGradient/types.ts b/src/components/LinearGradient/types.ts new file mode 100644 index 000000000000..cf6661eaecaa --- /dev/null +++ b/src/components/LinearGradient/types.ts @@ -0,0 +1,5 @@ +import LinearGradientNative from 'react-native-linear-gradient'; + +type LinearGradient = typeof LinearGradientNative; + +export default LinearGradient; diff --git a/src/components/MagicCodeInput.js b/src/components/MagicCodeInput.js index 585b7005ab1e..8119248c760d 100644 --- a/src/components/MagicCodeInput.js +++ b/src/components/MagicCodeInput.js @@ -355,6 +355,7 @@ function MagicCodeInput(props) { MagicCodeInput.propTypes = propTypes; MagicCodeInput.defaultProps = defaultProps; +MagicCodeInput.displayName = 'MagicCodeInput'; const MagicCodeInputWithRef = forwardRef((props, ref) => ( { if (option.accountID) { - const activeRoute = Navigation.getActiveRoute().replace(/\?.*/, ''); + const activeRoute = Navigation.getActiveRouteWithoutParams(); Navigation.navigate(ROUTES.PROFILE.getRoute(option.accountID, activeRoute)); } else if (option.reportID) { @@ -738,6 +738,7 @@ function MoneyRequestConfirmationList(props) { MoneyRequestConfirmationList.propTypes = propTypes; MoneyRequestConfirmationList.defaultProps = defaultProps; +MoneyRequestConfirmationList.displayName = 'MoneyRequestConfirmationList'; export default compose( withCurrentUserPersonalDetails, diff --git a/src/components/NewDatePicker/index.js b/src/components/NewDatePicker/index.js index fa4bab7d00ec..f03b4e2cb796 100644 --- a/src/components/NewDatePicker/index.js +++ b/src/components/NewDatePicker/index.js @@ -100,5 +100,6 @@ function NewDatePicker({containerStyles, defaultValue, disabled, errorText, inpu NewDatePicker.propTypes = propTypes; NewDatePicker.defaultProps = datePickerDefaultProps; +NewDatePicker.displayName = 'NewDatePicker'; export default withLocalize(NewDatePicker); diff --git a/src/components/OptionRow.js b/src/components/OptionRow.js index 2ce3bda63896..c2f272663c20 100644 --- a/src/components/OptionRow.js +++ b/src/components/OptionRow.js @@ -303,6 +303,7 @@ function OptionRow(props) { OptionRow.propTypes = propTypes; OptionRow.defaultProps = defaultProps; +OptionRow.displayName = 'OptionRow'; export default React.memo( withLocalize(OptionRow), diff --git a/src/components/Pressable/PressableWithDelayToggle.js b/src/components/Pressable/PressableWithDelayToggle.js index 7113afff8bdc..c9f05e5adfee 100644 --- a/src/components/Pressable/PressableWithDelayToggle.js +++ b/src/components/Pressable/PressableWithDelayToggle.js @@ -140,6 +140,7 @@ function PressableWithDelayToggle(props) { PressableWithDelayToggle.propTypes = propTypes; PressableWithDelayToggle.defaultProps = defaultProps; +PressableWithDelayToggle.displayName = 'PressableWithDelayToggle'; const PressableWithDelayToggleWithRef = React.forwardRef((props, ref) => ( Task.clearEditTaskErrors(props.report.reportID)} + errors={lodashGet(props, 'report.errorFields.editTask') || lodashGet(props, 'report.errorFields.createTask')} + onClose={() => Task.clearTaskErrors(props.report.reportID)} errorRowStyles={styles.ph5} > diff --git a/src/components/ScreenWrapper/index.js b/src/components/ScreenWrapper/index.js index 4563c7149e97..075cae568cf0 100644 --- a/src/components/ScreenWrapper/index.js +++ b/src/components/ScreenWrapper/index.js @@ -49,6 +49,10 @@ function ScreenWrapper({ const minHeight = shouldEnableMinHeight ? initialHeight : undefined; const isKeyboardShown = lodashGet(keyboardState, 'isKeyboardShown', false); + const isKeyboardShownRef = useRef(); + + isKeyboardShownRef.current = lodashGet(keyboardState, 'isKeyboardShown', false); + const panResponder = useRef( PanResponder.create({ onStartShouldSetPanResponderCapture: (e, gestureState) => gestureState.numberActiveTouches === CONST.TEST_TOOL.NUMBER_OF_TAPS, @@ -84,7 +88,7 @@ function ScreenWrapper({ // described here https://reactnavigation.org/docs/preventing-going-back/#limitations const beforeRemoveSubscription = shouldDismissKeyboardBeforeClose ? navigation.addListener('beforeRemove', () => { - if (!isKeyboardShown) { + if (!isKeyboardShownRef.current) { return; } Keyboard.dismiss(); @@ -118,7 +122,7 @@ function ScreenWrapper({ return ( diff --git a/src/components/SelectCircle.js b/src/components/SelectCircle.js deleted file mode 100644 index ce451e148030..000000000000 --- a/src/components/SelectCircle.js +++ /dev/null @@ -1,40 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import {View} from 'react-native'; -import styles from '@styles/styles'; -import themeColors from '@styles/themes/default'; -import Icon from './Icon'; -import * as Expensicons from './Icon/Expensicons'; - -const propTypes = { - /** Should we show the checkmark inside the circle */ - isChecked: PropTypes.bool, - - /** Additional styles to pass to SelectCircle */ - // eslint-disable-next-line react/forbid-prop-types - styles: PropTypes.arrayOf(PropTypes.object), -}; - -const defaultProps = { - isChecked: false, - styles: [], -}; - -function SelectCircle(props) { - return ( - - {props.isChecked && ( - - )} - - ); -} - -SelectCircle.propTypes = propTypes; -SelectCircle.defaultProps = defaultProps; -SelectCircle.displayName = 'SelectCircle'; - -export default SelectCircle; diff --git a/src/components/SelectCircle.tsx b/src/components/SelectCircle.tsx new file mode 100644 index 000000000000..cf8ee6af975d --- /dev/null +++ b/src/components/SelectCircle.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import {StyleProp, View, ViewStyle} from 'react-native'; +import globalStyles from '@styles/styles'; +import themeColors from '@styles/themes/default'; +import Icon from './Icon'; +import * as Expensicons from './Icon/Expensicons'; + +type SelectCircleProps = { + /** Should we show the checkmark inside the circle */ + isChecked: boolean; + + /** Additional styles to pass to SelectCircle */ + styles?: StyleProp; +}; + +function SelectCircle({isChecked = false, styles}: SelectCircleProps) { + return ( + + {isChecked && ( + + )} + + ); +} + +SelectCircle.displayName = 'SelectCircle'; + +export default SelectCircle; diff --git a/src/components/TextPill.js b/src/components/TextPill.js deleted file mode 100644 index 81bb0ca1e037..000000000000 --- a/src/components/TextPill.js +++ /dev/null @@ -1,38 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import stylePropTypes from '@styles/stylePropTypes'; -import styles from '@styles/styles'; -import * as StyleUtils from '@styles/StyleUtils'; -import Text from './Text'; - -const propTypes = { - text: PropTypes.string.isRequired, - - /** Text additional style */ - style: stylePropTypes, -}; - -const defaultProps = { - style: [], -}; - -function TextPill(props) { - const propsStyle = StyleUtils.parseStyleAsArray(props.style); - - return ( - - {props.text} - - ); -} - -TextPill.propTypes = propTypes; -TextPill.defaultProps = defaultProps; -TextPill.displayName = 'TextPill'; - -export default TextPill; diff --git a/src/components/ValidateCode/JustSignedInModal.js b/src/components/ValidateCode/JustSignedInModal.js index dd6085646120..143a967f1458 100644 --- a/src/components/ValidateCode/JustSignedInModal.js +++ b/src/components/ValidateCode/JustSignedInModal.js @@ -50,4 +50,6 @@ function JustSignedInModal(props) { } JustSignedInModal.propTypes = propTypes; +JustSignedInModal.displayName = 'JustSignedInModal'; + export default withLocalize(JustSignedInModal); diff --git a/src/components/ValidateCode/ValidateCodeModal.js b/src/components/ValidateCode/ValidateCodeModal.js index 173467d16b14..6faccef6c31f 100644 --- a/src/components/ValidateCode/ValidateCodeModal.js +++ b/src/components/ValidateCode/ValidateCodeModal.js @@ -81,6 +81,8 @@ function ValidateCodeModal(props) { ValidateCodeModal.propTypes = propTypes; ValidateCodeModal.defaultProps = defaultProps; +ValidateCodeModal.displayName = 'ValidateCodeModal'; + export default compose( withLocalize, withOnyx({ diff --git a/src/components/WalletStatementModal/index.js b/src/components/WalletStatementModal/index.js index 59508f8057ab..7b06dd02b898 100644 --- a/src/components/WalletStatementModal/index.js +++ b/src/components/WalletStatementModal/index.js @@ -67,6 +67,7 @@ function WalletStatementModal({statementPageURL, session}) { WalletStatementModal.propTypes = walletStatementPropTypes; WalletStatementModal.defaultProps = walletStatementDefaultProps; +WalletStatementModal.displayName = 'WalletStatementModal'; export default compose( withLocalize, diff --git a/src/hooks/useAutoFocusInput.js b/src/hooks/useAutoFocusInput.js index 275fed67f52d..181df9359fe8 100644 --- a/src/hooks/useAutoFocusInput.js +++ b/src/hooks/useAutoFocusInput.js @@ -14,6 +14,7 @@ export default function useAutoFocusInput() { return; } inputRef.current.focus(); + setIsScreenTransitionEnded(false); }, [isScreenTransitionEnded, isInputInitialized]); useFocusEffect( diff --git a/src/hooks/useInitialWindowDimensions/index.js b/src/hooks/useInitialWindowDimensions/index.js index 487b4e498228..5878c8b3371f 100644 --- a/src/hooks/useInitialWindowDimensions/index.js +++ b/src/hooks/useInitialWindowDimensions/index.js @@ -1,7 +1,6 @@ // eslint-disable-next-line no-restricted-imports import {useEffect, useState} from 'react'; import {Dimensions} from 'react-native'; -import {initialWindowMetrics} from 'react-native-safe-area-context'; /** * A convenience hook that provides initial size (width and height). @@ -50,10 +49,8 @@ export default function () { }; }, []); - const bottomInset = initialWindowMetrics && initialWindowMetrics.insets && initialWindowMetrics.insets.bottom ? initialWindowMetrics.insets.bottom : 0; - return { initialWidth: dimensions.initialWidth, - initialHeight: dimensions.initialHeight - bottomInset, + initialHeight: dimensions.initialHeight, }; } diff --git a/src/languages/en.ts b/src/languages/en.ts index c186a1fffedf..38efe0ef92f6 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -811,7 +811,6 @@ export default { title: 'Private notes', personalNoteMessage: 'Keep notes about this chat here. You are the only person who can add, edit or view these notes.', sharedNoteMessage: 'Keep notes about this chat here. Expensify employees and other users on the team.expensify.com domain can view these notes.', - notesUnavailable: 'No notes found for the user', composerLabel: 'Notes', myNote: 'My note', }, @@ -1638,6 +1637,7 @@ export default { markAsComplete: 'Mark as complete', markAsIncomplete: 'Mark as incomplete', assigneeError: 'There was an error assigning this task, please try another assignee.', + genericCreateTaskFailureMessage: 'Unexpected error create task, please try again later.', }, statementPage: { generatingPDF: "We're generating your PDF right now. Please come back later!", diff --git a/src/languages/es.ts b/src/languages/es.ts index a0a30bcf4141..2bdb71ae82f7 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -806,7 +806,6 @@ export default { title: 'Notas privadas', personalNoteMessage: 'Guarda notas sobre este chat aquí. Usted es la única persona que puede añadir, editar o ver estas notas.', sharedNoteMessage: 'Guarda notas sobre este chat aquí. Los empleados de Expensify y otros usuarios del dominio team.expensify.com pueden ver estas notas.', - notesUnavailable: 'No se han encontrado notas para el usuario', composerLabel: 'Notas', myNote: 'Mi nota', }, @@ -1661,6 +1660,7 @@ export default { markAsComplete: 'Marcar como completada', markAsIncomplete: 'Marcar como incompleta', assigneeError: 'Hubo un error al asignar esta tarea, inténtalo con otro usuario.', + genericCreateTaskFailureMessage: 'Error inesperado al crear el tarea, por favor, inténtalo más tarde.', }, statementPage: { generatingPDF: 'Estamos generando tu PDF ahora mismo. ¡Por favor, vuelve más tarde!', diff --git a/src/languages/translations.ts b/src/languages/translations.ts index d228394589b2..4d89f1f529de 100644 --- a/src/languages/translations.ts +++ b/src/languages/translations.ts @@ -46,5 +46,5 @@ export default { en: flattenObject(en), es: flattenObject(es), // eslint-disable-next-line @typescript-eslint/naming-convention - 'es-ES': esES, + 'es-ES': flattenObject(esES), }; diff --git a/src/languages/types.ts b/src/languages/types.ts index d2a387a329d0..5f6669315041 100644 --- a/src/languages/types.ts +++ b/src/languages/types.ts @@ -246,6 +246,7 @@ export type { EnglishTranslation, TranslationFlatObject, AddressLineParams, + TranslationPaths, CharacterLimitParams, MaxParticipantsReachedParams, ZipCodeExampleFormatParams, diff --git a/src/libs/DateUtils.ts b/src/libs/DateUtils.ts index 965d85134968..b956b5adcc51 100644 --- a/src/libs/DateUtils.ts +++ b/src/libs/DateUtils.ts @@ -135,7 +135,13 @@ function isYesterday(date: Date, timeZone: string): boolean { * Jan 20 at 5:30 PM within the past year * Jan 20, 2019 at 5:30 PM anything over 1 year ago */ -function datetimeToCalendarTime(locale: string, datetime: string, includeTimeZone = false, currentSelectedTimezone = timezone.selected, isLowercase = false): string { +function datetimeToCalendarTime( + locale: 'en' | 'es' | 'es-ES' | 'es_ES', + datetime: string, + includeTimeZone = false, + currentSelectedTimezone = timezone.selected, + isLowercase = false, +): string { const date = getLocalDateFromDatetime(locale, datetime, currentSelectedTimezone); const tz = includeTimeZone ? ' [UTC]Z' : ''; let todayAt = Localize.translate(locale, 'common.todayAt'); diff --git a/src/libs/E2E/API.mock.js b/src/libs/E2E/API.mock.js index 2c7da3f420a3..cecad375b22e 100644 --- a/src/libs/E2E/API.mock.js +++ b/src/libs/E2E/API.mock.js @@ -7,6 +7,7 @@ import mockAuthenticatePusher from './apiMocks/authenticatePusher'; import mockBeginSignin from './apiMocks/beginSignin'; import mockOpenApp from './apiMocks/openApp'; import mockOpenReport from './apiMocks/openReport'; +import mockReadNewestAction from './apiMocks/readNewestAction'; import mockSigninUser from './apiMocks/signinUser'; /** @@ -20,17 +21,23 @@ const mocks = { OpenApp: mockOpenApp, ReconnectApp: mockOpenApp, OpenReport: mockOpenReport, + ReconnectToReport: mockOpenReport, AuthenticatePusher: mockAuthenticatePusher, + ReadNewestAction: mockReadNewestAction, }; function mockCall(command, apiCommandParameters, tag) { const mockResponse = mocks[command] && mocks[command](apiCommandParameters); - if (!mockResponse || !_.isArray(mockResponse.onyxData)) { - Log.warn(`[${tag}] for command ${command} is not mocked yet!`); + if (!mockResponse) { + Log.warn(`[${tag}] for command ${command} is not mocked yet! ⚠️`); return; } - return Onyx.update(mockResponse.onyxData); + if (_.isArray(mockResponse.onyxData)) { + return Onyx.update(mockResponse.onyxData); + } + + return Promise.resolve(mockResponse); } /** diff --git a/src/libs/E2E/actions/waitForKeyboard.js b/src/libs/E2E/actions/waitForKeyboard.js new file mode 100644 index 000000000000..4bc0f492e3a3 --- /dev/null +++ b/src/libs/E2E/actions/waitForKeyboard.js @@ -0,0 +1,15 @@ +import {Keyboard} from 'react-native'; + +export default function waitForKeyboard() { + return new Promise((resolve) => { + function checkKeyboard() { + if (Keyboard.isVisible()) { + resolve(); + } else { + console.debug(`[E2E] Waiting for keyboard to appear…`); + setTimeout(checkKeyboard, 1000); + } + } + checkKeyboard(); + }); +} diff --git a/src/libs/E2E/apiMocks/openReport.js b/src/libs/E2E/apiMocks/openReport.js index 936f9d77ef06..b20b3df35bad 100644 --- a/src/libs/E2E/apiMocks/openReport.js +++ b/src/libs/E2E/apiMocks/openReport.js @@ -6,91 +6,1969 @@ export default () => ({ value: { reportID: '98345625', reportName: 'Chat Report', + type: 'chat', chatType: '', + ownerEmail: '__fake__', ownerAccountID: 0, + managerEmail: '__fake__', + managerID: 0, policyID: '_FAKE_', - participantAccountIDs: [2, 1, 4, 3, 5, 16, 18, 19], + participantAccountIDs: [14567013], isPinned: false, - lastReadCreated: '1980-01-01 00:00:00.000', - lastVisibleActionCreated: '2022-08-01 20:49:11', - lastMessageTimestamp: 1659386951000, - lastMessageText: 'Say hello\ud83d\ude10', - lastActorAccountID: 10773236, + lastReadTime: '2023-09-14 11:50:21.768', + lastMentionedTime: '2023-07-27 07:37:43.100', + lastReadSequenceNumber: 0, + lastVisibleActionCreated: '2023-08-29 12:38:16.070', + lastVisibleActionLastModified: '2023-08-29 12:38:16.070', + lastMessageText: 'terry+hightraffic@margelo.io owes \u20ac12.00', + lastActorAccountID: 14567013, notificationPreference: 'always', + welcomeMessage: '', stateNum: 0, statusNum: 0, oldPolicyName: '', visibility: null, isOwnPolicyExpenseChat: false, - lastMessageHtml: 'Say hello\ud83d\ude10', + lastMessageHtml: 'terry+hightraffic@margelo.io owes \u20ac12.00', + iouReportID: 206636935813547, hasOutstandingIOU: false, + hasOutstandingChildRequest: false, + policyName: null, + hasParentAccess: null, + parentReportID: null, + parentReportActionID: null, + writeCapability: 'all', + description: null, + isDeletedParentAction: null, + total: 0, + currency: 'USD', + submitterPayPalMeAddress: '', + chatReportID: null, + isWaitingOnBankAccount: false, + }, + }, + { + onyxMethod: 'mergecollection', + key: 'transactions_', + value: { + transactions_5509240412000765850: { + amount: 1200, + billable: false, + cardID: 15467728, + category: '', + comment: { + comment: '', + }, + created: '2023-08-29', + currency: 'EUR', + filename: '', + merchant: 'Request', + modifiedAmount: 0, + modifiedCreated: '', + modifiedCurrency: '', + modifiedMerchant: '', + originalAmount: 0, + originalCurrency: '', + parentTransactionID: '', + receipt: {}, + reimbursable: true, + reportID: '206636935813547', + status: 'Pending', + tag: '', + transactionID: '5509240412000765850', + hasEReceipt: false, + }, }, }, { onyxMethod: 'merge', key: 'reportActions_98345625', value: { - 226245034: { - reportActionID: '226245034', - actionName: 'CREATED', - created: '2022-08-01 20:48:58', - timestamp: 1659386938, - reportActionTimestamp: 0, - avatar: 'https://d2k5nsl2zxldvw.cloudfront.net/images/avatars/avatar_3.png', + '885570376575240776': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Hanno J. G\u00f6decke', + }, + ], + actorAccountID: 12883048, + message: [ + { + type: 'COMMENT', + html: '', + text: '', + isEdited: true, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + edits: [], + html: '', + lastModified: '2023-09-01 07:43:29.374', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', + created: '2023-08-31 07:23:52.892', + timestamp: 1693466632, + reportActionTimestamp: 1693466632892, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '885570376575240776', + previousReportActionID: '6576518341807837187', + lastModified: '2023-09-01 07:43:29.374', + whisperedToAccountIDs: [], + }, + '6576518341807837187': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Terry Hightraffic1337', + }, + ], + actorAccountID: 14567013, + message: [ + { + type: 'COMMENT', + html: 'terry+hightraffic@margelo.io owes \u20ac12.00', + text: 'terry+hightraffic@margelo.io owes \u20ac12.00', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + lastModified: '2023-08-29 12:38:16.070', + linkedReportID: '206636935813547', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', + created: '2023-08-29 12:38:16.070', + timestamp: 1693312696, + reportActionTimestamp: 1693312696070, + automatic: false, + actionName: 'REPORTPREVIEW', + shouldShow: true, + reportActionID: '6576518341807837187', + previousReportActionID: '2658221912430757962', + lastModified: '2023-08-29 12:38:16.070', + childReportID: 206636935813547, + childType: 'iou', + childStatusNum: 1, + childStateNum: 1, + childMoneyRequestCount: 1, + whisperedToAccountIDs: [], + }, + '2658221912430757962': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Hanno J. G\u00f6decke', + }, + ], + actorAccountID: 12883048, + message: [ + { + type: 'COMMENT', + html: 'Hshshdhdhejje
Cuududdke

F
D
R
D
R
Jfj c
D

D
D
R
D
R', + text: 'Hshshdhdhejje\nCuududdke\n\nF\nD\nR\nD\nR\nJfj c\nD\n\nD\nD\nR\nD\nR', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [ + { + emoji: 'heart', + users: [ + { + accountID: 12883048, + skinTone: -1, + }, + ], + }, + ], + }, + ], + originalMessage: { + html: 'Hshshdhdhejje
Cuududdke

F
D
R
D
R
Jfj c
D

D
D
R
D
R', + lastModified: '2023-08-25 12:39:48.121', + reactions: [ + { + emoji: 'heart', + users: [ + { + accountID: 12883048, + skinTone: -1, + }, + ], + }, + ], + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', + created: '2023-08-25 08:54:06.972', + timestamp: 1692953646, + reportActionTimestamp: 1692953646972, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '2658221912430757962', + previousReportActionID: '6551789403725495383', + lastModified: '2023-08-25 12:39:48.121', + childReportID: 1411015346900020, + childType: 'chat', + childOldestFourAccountIDs: '12883048', + childCommenterCount: 1, + childLastVisibleActionCreated: '2023-08-29 06:08:59.247', + childVisibleActionCount: 1, + whisperedToAccountIDs: [], + }, + '6551789403725495383': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Hanno J. G\u00f6decke', + }, + ], + actorAccountID: 12883048, + message: [ + { + type: 'COMMENT', + html: 'Typing with the composer is now also reasonably fast again', + text: 'Typing with the composer is now also reasonably fast again', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'Typing with the composer is now also reasonably fast again', + lastModified: '2023-08-25 08:53:57.490', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', + created: '2023-08-25 08:53:57.490', + timestamp: 1692953637, + reportActionTimestamp: 1692953637490, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '6551789403725495383', + previousReportActionID: '6184477005811241106', + lastModified: '2023-08-25 08:53:57.490', + whisperedToAccountIDs: [], + }, + '6184477005811241106': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Hanno J. G\u00f6decke', + }, + ], + actorAccountID: 12883048, + message: [ + { + type: 'COMMENT', + html: '\ud83d\ude3a', + text: '\ud83d\ude3a', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: '\ud83d\ude3a', + lastModified: '2023-08-25 08:53:41.689', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', + created: '2023-08-25 08:53:41.689', + timestamp: 1692953621, + reportActionTimestamp: 1692953621689, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '6184477005811241106', + previousReportActionID: '7473953427765241164', + lastModified: '2023-08-25 08:53:41.689', + whisperedToAccountIDs: [], + }, + '7473953427765241164': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Hanno J. G\u00f6decke', + }, + ], + actorAccountID: 12883048, + message: [ + { + type: 'COMMENT', + html: 'Skkkkkkrrrrrrrr', + text: 'Skkkkkkrrrrrrrr', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'Skkkkkkrrrrrrrr', + lastModified: '2023-08-25 08:53:31.900', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', + created: '2023-08-25 08:53:31.900', + timestamp: 1692953611, + reportActionTimestamp: 1692953611900, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '7473953427765241164', + previousReportActionID: '872421684593496491', + lastModified: '2023-08-25 08:53:31.900', + whisperedToAccountIDs: [], + }, + '872421684593496491': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Hanno J. G\u00f6decke', + }, + ], + actorAccountID: 12883048, + message: [ + { + type: 'COMMENT', + html: 'hello this is a new test will my version sync though? i doubt it lolasdasdasdaoe f t asdasd okay und das ging jetzt eh oder? ja schaut ganz gut aus okay geht das immer noch ? schaut gut aus ja true ghw test test 2 test 4 tse 3 oida', + text: 'hello this is a new test will my version sync though? i doubt it lolasdasdasdaoe f t asdasd okay und das ging jetzt eh oder? ja schaut ganz gut aus okay geht das immer noch ? schaut gut aus ja true ghw test test 2 test 4 tse 3 oida', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'hello this is a new test will my version sync though? i doubt it lolasdasdasdaoe f t asdasd okay und das ging jetzt eh oder? ja schaut ganz gut aus okay geht das immer noch ? schaut gut aus ja true ghw test test 2 test 4 tse 3 oida', + lastModified: '2023-08-11 13:35:03.962', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', + created: '2023-08-11 13:35:03.962', + timestamp: 1691760903, + reportActionTimestamp: 1691760903962, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '872421684593496491', + previousReportActionID: '175680146540578558', + lastModified: '2023-08-11 13:35:03.962', + whisperedToAccountIDs: [], + }, + '175680146540578558': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Hanno J. G\u00f6decke', + }, + ], + actorAccountID: 12883048, + message: [ + { + type: 'COMMENT', + html: '', + text: '[Attachment]', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: '', + lastModified: '2023-08-10 06:59:21.381', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', + created: '2023-08-10 06:59:21.381', + timestamp: 1691650761, + reportActionTimestamp: 1691650761381, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '175680146540578558', + previousReportActionID: '1264289784533901723', + lastModified: '2023-08-10 06:59:21.381', + whisperedToAccountIDs: [], + }, + '1264289784533901723': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Hanno J. G\u00f6decke', + }, + ], + actorAccountID: 12883048, + message: [ + { + type: 'COMMENT', + html: '', + text: '[Attachment]', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: '', + lastModified: '2023-08-10 06:59:16.922', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', + created: '2023-08-10 06:59:16.922', + timestamp: 1691650756, + reportActionTimestamp: 1691650756922, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '1264289784533901723', + previousReportActionID: '4870277010164688289', + lastModified: '2023-08-10 06:59:16.922', + whisperedToAccountIDs: [], + }, + '4870277010164688289': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Hanno J. G\u00f6decke', + }, + ], + actorAccountID: 12883048, + message: [ + { + type: 'COMMENT', + html: 'send test', + text: 'send test', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'send test', + lastModified: '2023-08-09 06:43:25.209', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', + created: '2023-08-09 06:43:25.209', + timestamp: 1691563405, + reportActionTimestamp: 1691563405209, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '4870277010164688289', + previousReportActionID: '7931783095143103530', + lastModified: '2023-08-09 06:43:25.209', + whisperedToAccountIDs: [], + }, + '7931783095143103530': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Hanno J. G\u00f6decke', + }, + ], + actorAccountID: 12883048, + message: [ + { + type: 'COMMENT', + html: 'hello terry \ud83d\ude04 this is a test @terry+hightraffic@margelo.io', + text: 'hello terry \ud83d\ude04 this is a test @terry+hightraffic@margelo.io', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'hello terry \ud83d\ude04 this is a test @terry+hightraffic@margelo.io', + lastModified: '2023-08-08 14:38:45.035', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', + created: '2023-08-08 14:38:45.035', + timestamp: 1691505525, + reportActionTimestamp: 1691505525035, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '7931783095143103530', + previousReportActionID: '4598496324774172433', + lastModified: '2023-08-08 14:38:45.035', + whisperedToAccountIDs: [], + }, + '4598496324774172433': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Hanno J. G\u00f6decke', + }, + ], + actorAccountID: 12883048, message: [ + { + type: 'COMMENT', + html: '\ud83d\uddff', + text: '\ud83d\uddff', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: '\ud83d\uddff', + lastModified: '2023-08-08 13:21:42.102', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', + created: '2023-08-08 13:21:42.102', + timestamp: 1691500902, + reportActionTimestamp: 1691500902102, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '4598496324774172433', + previousReportActionID: '3324110555952451144', + lastModified: '2023-08-08 13:21:42.102', + whisperedToAccountIDs: [], + }, + '3324110555952451144': { + person: [ { type: 'TEXT', style: 'strong', - text: '__fake__', + text: 'Hanno J. G\u00f6decke', + }, + ], + actorAccountID: 12883048, + message: [ + { + type: 'COMMENT', + html: 'test \ud83d\uddff', + text: 'test \ud83d\uddff', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], }, + ], + originalMessage: { + html: 'test \ud83d\uddff', + lastModified: '2023-08-08 13:21:32.101', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', + created: '2023-08-08 13:21:32.101', + timestamp: 1691500892, + reportActionTimestamp: 1691500892101, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '3324110555952451144', + previousReportActionID: '5389364980227777980', + lastModified: '2023-08-08 13:21:32.101', + whisperedToAccountIDs: [], + }, + '5389364980227777980': { + person: [ { type: 'TEXT', - style: 'normal', - text: ' created this report', + style: 'strong', + text: 'Hanno J. G\u00f6decke', + }, + ], + actorAccountID: 12883048, + message: [ + { + type: 'COMMENT', + html: 'okay now it will work again y \ud83d\udc42', + text: 'okay now it will work again y \ud83d\udc42', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], }, ], + originalMessage: { + html: 'okay now it will work again y \ud83d\udc42', + lastModified: '2023-08-07 10:54:38.141', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', + created: '2023-08-07 10:54:38.141', + timestamp: 1691405678, + reportActionTimestamp: 1691405678141, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '5389364980227777980', + previousReportActionID: '4717622390560689493', + lastModified: '2023-08-07 10:54:38.141', + whisperedToAccountIDs: [], + }, + '4717622390560689493': { person: [ { type: 'TEXT', style: 'strong', - text: '__fake__', + text: 'Hanno J. G\u00f6decke', + }, + ], + actorAccountID: 12883048, + message: [ + { + type: 'COMMENT', + html: 'hmmmm', + text: 'hmmmm', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], }, ], + originalMessage: { + html: 'hmmmm', + lastModified: '2023-07-27 18:13:45.322', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', + created: '2023-07-27 18:13:45.322', + timestamp: 1690481625, + reportActionTimestamp: 1690481625322, automatic: false, + actionName: 'ADDCOMMENT', shouldShow: true, + reportActionID: '4717622390560689493', + previousReportActionID: '745721424446883075', + lastModified: '2023-07-27 18:13:45.322', + whisperedToAccountIDs: [], }, - 1082059149: { + '745721424446883075': { person: [ { type: 'TEXT', style: 'strong', - text: '123 Ios', + text: 'Hanno J. G\u00f6decke', }, ], - actorAccountID: 10773236, + actorAccountID: 12883048, message: [ { type: 'COMMENT', - html: 'Say hello\ud83d\ude10', - text: 'Say hello\ud83d\ude10', + html: 'test', + text: 'test', isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], }, ], originalMessage: { - html: 'Say hello\ud83d\ude10', + html: 'test', + lastModified: '2023-07-27 18:13:32.595', }, - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/301e37631eca9e3127d6b668822e3a53771551f6_128.jpeg', - created: '2022-08-01 20:49:11', - timestamp: 1659386951, - reportActionTimestamp: 1659386951000, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', + created: '2023-07-27 18:13:32.595', + timestamp: 1690481612, + reportActionTimestamp: 1690481612595, automatic: false, actionName: 'ADDCOMMENT', shouldShow: true, - reportActionID: '1082059149', + reportActionID: '745721424446883075', + previousReportActionID: '3986429677777110818', + lastModified: '2023-07-27 18:13:32.595', + whisperedToAccountIDs: [], + }, + '3986429677777110818': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Terry Hightraffic1337', + }, + ], + actorAccountID: 14567013, + message: [ + { + type: 'COMMENT', + html: 'I will', + text: 'I will', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'I will', + lastModified: '2023-07-27 17:03:11.250', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', + created: '2023-07-27 17:03:11.250', + timestamp: 1690477391, + reportActionTimestamp: 1690477391250, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '3986429677777110818', + previousReportActionID: '7317910228472011573', + lastModified: '2023-07-27 17:03:11.250', + childReportID: 3338245207149134, + childType: 'chat', + whisperedToAccountIDs: [], + }, + '7317910228472011573': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Hanno J. G\u00f6decke', + }, + ], + actorAccountID: 12883048, + message: [ + { + type: 'COMMENT', + html: 'will you>', + text: 'will you>', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'will you>', + lastModified: '2023-07-27 16:46:58.988', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', + created: '2023-07-27 16:46:58.988', + timestamp: 1690476418, + reportActionTimestamp: 1690476418988, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '7317910228472011573', + previousReportActionID: '6779343397958390319', + lastModified: '2023-07-27 16:46:58.988', + whisperedToAccountIDs: [], + }, + '6779343397958390319': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Terry Hightraffic1337', + }, + ], + actorAccountID: 14567013, + message: [ + { + type: 'COMMENT', + html: 'i will always send :#', + text: 'i will always send :#', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'i will always send :#', + lastModified: '2023-07-27 07:55:33.468', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', + created: '2023-07-27 07:55:33.468', + timestamp: 1690444533, + reportActionTimestamp: 1690444533468, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '6779343397958390319', + previousReportActionID: '5084145419388195535', + lastModified: '2023-07-27 07:55:33.468', + whisperedToAccountIDs: [], + }, + '5084145419388195535': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Terry Hightraffic1337', + }, + ], + actorAccountID: 14567013, + message: [ + { + type: 'COMMENT', + html: 'new test', + text: 'new test', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'new test', + lastModified: '2023-07-27 07:55:22.309', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', + created: '2023-07-27 07:55:22.309', + timestamp: 1690444522, + reportActionTimestamp: 1690444522309, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '5084145419388195535', + previousReportActionID: '6742067600980190659', + lastModified: '2023-07-27 07:55:22.309', + whisperedToAccountIDs: [], + }, + '6742067600980190659': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Terry Hightraffic1337', + }, + ], + actorAccountID: 14567013, + message: [ + { + type: 'COMMENT', + html: 'okay good', + text: 'okay good', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'okay good', + lastModified: '2023-07-27 07:55:15.362', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', + created: '2023-07-27 07:55:15.362', + timestamp: 1690444515, + reportActionTimestamp: 1690444515362, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '6742067600980190659', + previousReportActionID: '7811212427986810247', + lastModified: '2023-07-27 07:55:15.362', + whisperedToAccountIDs: [], + }, + '7811212427986810247': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Terry Hightraffic1337', + }, + ], + actorAccountID: 14567013, + message: [ + { + type: 'COMMENT', + html: 'test 2', + text: 'test 2', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'test 2', + lastModified: '2023-07-27 07:55:10.629', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', + created: '2023-07-27 07:55:10.629', + timestamp: 1690444510, + reportActionTimestamp: 1690444510629, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '7811212427986810247', + previousReportActionID: '4544757211729131829', + lastModified: '2023-07-27 07:55:10.629', + whisperedToAccountIDs: [], + }, + '4544757211729131829': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Terry Hightraffic1337', + }, + ], + actorAccountID: 14567013, + message: [ + { + type: 'COMMENT', + html: 'new test', + text: 'new test', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'new test', + lastModified: '2023-07-27 07:53:41.960', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', + created: '2023-07-27 07:53:41.960', + timestamp: 1690444421, + reportActionTimestamp: 1690444421960, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '4544757211729131829', + previousReportActionID: '8290114634148431001', + lastModified: '2023-07-27 07:53:41.960', + whisperedToAccountIDs: [], + }, + '8290114634148431001': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Terry Hightraffic1337', + }, + ], + actorAccountID: 14567013, + message: [ + { + type: 'COMMENT', + html: 'something was real', + text: 'something was real', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'something was real', + lastModified: '2023-07-27 07:53:27.836', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', + created: '2023-07-27 07:53:27.836', + timestamp: 1690444407, + reportActionTimestamp: 1690444407836, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '8290114634148431001', + previousReportActionID: '5597494166918965742', + lastModified: '2023-07-27 07:53:27.836', + whisperedToAccountIDs: [], + }, + '5597494166918965742': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Terry Hightraffic1337', + }, + ], + actorAccountID: 14567013, + message: [ + { + type: 'COMMENT', + html: 'oida', + text: 'oida', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'oida', + lastModified: '2023-07-27 07:53:20.783', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', + created: '2023-07-27 07:53:20.783', + timestamp: 1690444400, + reportActionTimestamp: 1690444400783, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '5597494166918965742', + previousReportActionID: '7445709165354739065', + lastModified: '2023-07-27 07:53:20.783', + whisperedToAccountIDs: [], + }, + '7445709165354739065': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Terry Hightraffic1337', + }, + ], + actorAccountID: 14567013, + message: [ + { + type: 'COMMENT', + html: 'test 12', + text: 'test 12', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'test 12', + lastModified: '2023-07-27 07:53:17.393', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', + created: '2023-07-27 07:53:17.393', + timestamp: 1690444397, + reportActionTimestamp: 1690444397393, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '7445709165354739065', + previousReportActionID: '1985264407541504554', + lastModified: '2023-07-27 07:53:17.393', + whisperedToAccountIDs: [], + }, + '1985264407541504554': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Terry Hightraffic1337', + }, + ], + actorAccountID: 14567013, + message: [ + { + type: 'COMMENT', + html: 'new test', + text: 'new test', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'new test', + lastModified: '2023-07-27 07:53:07.894', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', + created: '2023-07-27 07:53:07.894', + timestamp: 1690444387, + reportActionTimestamp: 1690444387894, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '1985264407541504554', + previousReportActionID: '6101278009725036288', + lastModified: '2023-07-27 07:53:07.894', + whisperedToAccountIDs: [], + }, + '6101278009725036288': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Terry Hightraffic1337', + }, + ], + actorAccountID: 14567013, + message: [ + { + type: 'COMMENT', + html: 'grrr', + text: 'grrr', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'grrr', + lastModified: '2023-07-27 07:52:56.421', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', + created: '2023-07-27 07:52:56.421', + timestamp: 1690444376, + reportActionTimestamp: 1690444376421, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '6101278009725036288', + previousReportActionID: '6913024396112106680', + lastModified: '2023-07-27 07:52:56.421', + whisperedToAccountIDs: [], + }, + '6913024396112106680': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Terry Hightraffic1337', + }, + ], + actorAccountID: 14567013, + message: [ + { + type: 'COMMENT', + html: 'ne w test', + text: 'ne w test', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'ne w test', + lastModified: '2023-07-27 07:52:53.352', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', + created: '2023-07-27 07:52:53.352', + timestamp: 1690444373, + reportActionTimestamp: 1690444373352, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '6913024396112106680', + previousReportActionID: '3663318486255461038', + lastModified: '2023-07-27 07:52:53.352', + whisperedToAccountIDs: [], + }, + '3663318486255461038': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Terry Hightraffic1337', + }, + ], + actorAccountID: 14567013, + message: [ + { + type: 'COMMENT', + html: 'well', + text: 'well', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'well', + lastModified: '2023-07-27 07:52:47.044', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', + created: '2023-07-27 07:52:47.044', + timestamp: 1690444367, + reportActionTimestamp: 1690444367044, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '3663318486255461038', + previousReportActionID: '6652909175804277965', + lastModified: '2023-07-27 07:52:47.044', + whisperedToAccountIDs: [], + }, + '6652909175804277965': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Terry Hightraffic1337', + }, + ], + actorAccountID: 14567013, + message: [ + { + type: 'COMMENT', + html: 'hu', + text: 'hu', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'hu', + lastModified: '2023-07-27 07:52:43.489', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', + created: '2023-07-27 07:52:43.489', + timestamp: 1690444363, + reportActionTimestamp: 1690444363489, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '6652909175804277965', + previousReportActionID: '4738491624635492834', + lastModified: '2023-07-27 07:52:43.489', + whisperedToAccountIDs: [], + }, + '4738491624635492834': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Terry Hightraffic1337', + }, + ], + actorAccountID: 14567013, + message: [ + { + type: 'COMMENT', + html: 'test', + text: 'test', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'test', + lastModified: '2023-07-27 07:52:40.145', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', + created: '2023-07-27 07:52:40.145', + timestamp: 1690444360, + reportActionTimestamp: 1690444360145, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '4738491624635492834', + previousReportActionID: '1621235410433805703', + lastModified: '2023-07-27 07:52:40.145', + whisperedToAccountIDs: [], + }, + '1621235410433805703': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Hanno J. G\u00f6decke', + }, + ], + actorAccountID: 12883048, + message: [ + { + type: 'COMMENT', + html: 'test 4', + text: 'test 4', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'test 4', + lastModified: '2023-07-27 07:48:36.809', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', + created: '2023-07-27 07:48:36.809', + timestamp: 1690444116, + reportActionTimestamp: 1690444116809, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '1621235410433805703', + previousReportActionID: '1024550225871474566', + lastModified: '2023-07-27 07:48:36.809', + whisperedToAccountIDs: [], + }, + '1024550225871474566': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Terry Hightraffic1337', + }, + ], + actorAccountID: 14567013, + message: [ + { + type: 'COMMENT', + html: 'test 3', + text: 'test 3', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'test 3', + lastModified: '2023-07-27 07:48:24.183', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', + created: '2023-07-27 07:48:24.183', + timestamp: 1690444104, + reportActionTimestamp: 1690444104183, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '1024550225871474566', + previousReportActionID: '5598482410513625723', + lastModified: '2023-07-27 07:48:24.183', + whisperedToAccountIDs: [], + }, + '5598482410513625723': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Terry Hightraffic1337', + }, + ], + actorAccountID: 14567013, + message: [ + { + type: 'COMMENT', + html: 'test2', + text: 'test2', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'test2', + lastModified: '2023-07-27 07:42:25.340', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', + created: '2023-07-27 07:42:25.340', + timestamp: 1690443745, + reportActionTimestamp: 1690443745340, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '5598482410513625723', + previousReportActionID: '115121137377026405', + lastModified: '2023-07-27 07:42:25.340', + whisperedToAccountIDs: [], + }, + '115121137377026405': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Hanno J. G\u00f6decke', + }, + ], + actorAccountID: 12883048, + message: [ + { + type: 'COMMENT', + html: 'test', + text: 'test', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'test', + lastModified: '2023-07-27 07:42:22.583', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', + created: '2023-07-27 07:42:22.583', + timestamp: 1690443742, + reportActionTimestamp: 1690443742583, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '115121137377026405', + previousReportActionID: '2167420855737359171', + lastModified: '2023-07-27 07:42:22.583', + whisperedToAccountIDs: [], + }, + '2167420855737359171': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Terry Hightraffic1337', + }, + ], + actorAccountID: 14567013, + message: [ + { + type: 'COMMENT', + html: 'new message', + text: 'new message', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'new message', + lastModified: '2023-07-27 07:42:09.177', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', + created: '2023-07-27 07:42:09.177', + timestamp: 1690443729, + reportActionTimestamp: 1690443729177, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '2167420855737359171', + previousReportActionID: '6106926938128802897', + lastModified: '2023-07-27 07:42:09.177', + whisperedToAccountIDs: [], + }, + '6106926938128802897': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Terry Hightraffic1337', + }, + ], + actorAccountID: 14567013, + message: [ + { + type: 'COMMENT', + html: 'oh', + text: 'oh', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'oh', + lastModified: '2023-07-27 07:42:03.902', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', + created: '2023-07-27 07:42:03.902', + timestamp: 1690443723, + reportActionTimestamp: 1690443723902, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '6106926938128802897', + previousReportActionID: '4366704007455141347', + lastModified: '2023-07-27 07:42:03.902', + whisperedToAccountIDs: [], + }, + '4366704007455141347': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Terry Hightraffic1337', + }, + ], + actorAccountID: 14567013, + message: [ + { + type: 'COMMENT', + html: 'hm lol', + text: 'hm lol', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'hm lol', + lastModified: '2023-07-27 07:42:00.734', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', + created: '2023-07-27 07:42:00.734', + timestamp: 1690443720, + reportActionTimestamp: 1690443720734, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '4366704007455141347', + previousReportActionID: '2078794664797360607', + lastModified: '2023-07-27 07:42:00.734', + whisperedToAccountIDs: [], + }, + '2078794664797360607': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Terry Hightraffic1337', + }, + ], + actorAccountID: 14567013, + message: [ + { + type: 'COMMENT', + html: 'hi?', + text: 'hi?', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'hi?', + lastModified: '2023-07-27 07:41:49.724', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', + created: '2023-07-27 07:41:49.724', + timestamp: 1690443709, + reportActionTimestamp: 1690443709724, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '2078794664797360607', + previousReportActionID: '2030060194258527427', + lastModified: '2023-07-27 07:41:49.724', + whisperedToAccountIDs: [], + }, + '2030060194258527427': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Hanno J. G\u00f6decke', + }, + ], + actorAccountID: 12883048, + message: [ + { + type: 'COMMENT', + html: 'lets have a thread about it, will ya?', + text: 'lets have a thread about it, will ya?', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'lets have a thread about it, will ya?', + lastModified: '2023-07-27 07:40:49.146', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', + created: '2023-07-27 07:40:49.146', + timestamp: 1690443649, + reportActionTimestamp: 1690443649146, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '2030060194258527427', + previousReportActionID: '5540483153987237906', + lastModified: '2023-07-27 07:40:49.146', + childReportID: 5860710623453234, + childType: 'chat', + childOldestFourAccountIDs: '14567013,12883048', + childCommenterCount: 2, + childLastVisibleActionCreated: '2023-07-27 07:41:03.550', + childVisibleActionCount: 2, + whisperedToAccountIDs: [], + }, + '5540483153987237906': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Terry Hightraffic1337', + }, + ], + actorAccountID: 14567013, + message: [ + { + type: 'COMMENT', + html: '@hanno@margelo.io i mention you lasagna :)', + text: '@hanno@margelo.io i mention you lasagna :)', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: '@hanno@margelo.io i mention you lasagna :)', + lastModified: '2023-07-27 07:37:43.100', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', + created: '2023-07-27 07:37:43.100', + timestamp: 1690443463, + reportActionTimestamp: 1690443463100, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '5540483153987237906', + previousReportActionID: '8050559753491913991', + lastModified: '2023-07-27 07:37:43.100', + whisperedToAccountIDs: [], + }, + '8050559753491913991': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Terry Hightraffic1337', + }, + ], + actorAccountID: 14567013, + message: [ + { + type: 'COMMENT', + html: '@terry+hightraffic@margelo.io', + text: '@terry+hightraffic@margelo.io', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: '@terry+hightraffic@margelo.io', + lastModified: '2023-07-27 07:36:41.708', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', + created: '2023-07-27 07:36:41.708', + timestamp: 1690443401, + reportActionTimestamp: 1690443401708, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '8050559753491913991', + previousReportActionID: '881015235172878574', + lastModified: '2023-07-27 07:36:41.708', + whisperedToAccountIDs: [], + }, + '881015235172878574': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Terry Hightraffic1337', + }, + ], + actorAccountID: 14567013, + message: [ + { + type: 'COMMENT', + html: 'yeah lets see', + text: 'yeah lets see', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'yeah lets see', + lastModified: '2023-07-27 07:25:15.997', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', + created: '2023-07-27 07:25:15.997', + timestamp: 1690442715, + reportActionTimestamp: 1690442715997, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '881015235172878574', + previousReportActionID: '4800357767877651330', + lastModified: '2023-07-27 07:25:15.997', + whisperedToAccountIDs: [], + }, + '4800357767877651330': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Hanno J. G\u00f6decke', + }, + ], + actorAccountID: 12883048, + message: [ + { + type: 'COMMENT', + html: 'asdasdasd', + text: 'asdasdasd', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'asdasdasd', + lastModified: '2023-07-27 07:25:03.093', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', + created: '2023-07-27 07:25:03.093', + timestamp: 1690442703, + reportActionTimestamp: 1690442703093, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '4800357767877651330', + previousReportActionID: '9012557872554910346', + lastModified: '2023-07-27 07:25:03.093', + whisperedToAccountIDs: [], + }, + '9012557872554910346': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Terry Hightraffic1337', + }, + ], + actorAccountID: 14567013, + message: [ + { + type: 'COMMENT', + html: 'yeah', + text: 'yeah', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'yeah', + lastModified: '2023-07-26 19:49:40.471', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', + created: '2023-07-26 19:49:40.471', + timestamp: 1690400980, + reportActionTimestamp: 1690400980471, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '9012557872554910346', + previousReportActionID: '8440677969068645500', + lastModified: '2023-07-26 19:49:40.471', + whisperedToAccountIDs: [], + }, + '8440677969068645500': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Hanno J. G\u00f6decke', + }, + ], + actorAccountID: 12883048, + message: [ + { + type: 'COMMENT', + html: 'hello motor', + text: 'hello motor', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'hello motor', + lastModified: '2023-07-26 19:49:36.262', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', + created: '2023-07-26 19:49:36.262', + timestamp: 1690400976, + reportActionTimestamp: 1690400976262, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '8440677969068645500', + previousReportActionID: '306887996337608775', + lastModified: '2023-07-26 19:49:36.262', + whisperedToAccountIDs: [], + }, + '306887996337608775': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Hanno J. G\u00f6decke', + }, + ], + actorAccountID: 12883048, + message: [ + { + type: 'COMMENT', + html: 'a new messagfe', + text: 'a new messagfe', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'a new messagfe', + lastModified: '2023-07-26 19:49:29.512', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', + created: '2023-07-26 19:49:29.512', + timestamp: 1690400969, + reportActionTimestamp: 1690400969512, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '306887996337608775', + previousReportActionID: '587892433077506227', + lastModified: '2023-07-26 19:49:29.512', + whisperedToAccountIDs: [], + }, + '587892433077506227': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Hanno J. G\u00f6decke', + }, + ], + actorAccountID: 12883048, + message: [ + { + type: 'COMMENT', + html: 'good', + text: 'good', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'good', + lastModified: '2023-07-26 19:49:20.473', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', + created: '2023-07-26 19:49:20.473', + timestamp: 1690400960, + reportActionTimestamp: 1690400960473, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '587892433077506227', + previousReportActionID: '1433103421804347060', + lastModified: '2023-07-26 19:49:20.473', + whisperedToAccountIDs: [], + }, + '1433103421804347060': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Terry Hightraffic1337', + }, + ], + actorAccountID: 14567013, + message: [ + { + type: 'COMMENT', + html: 'ah', + text: 'ah', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'ah', + lastModified: '2023-07-26 19:49:12.762', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', + created: '2023-07-26 19:49:12.762', + timestamp: 1690400952, + reportActionTimestamp: 1690400952762, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '1433103421804347060', + previousReportActionID: '8774157052628183778', + lastModified: '2023-07-26 19:49:12.762', + whisperedToAccountIDs: [], + }, + }, + }, + { + onyxMethod: 'mergecollection', + key: 'reportActionsReactions_', + value: { + reportActionsReactions_2658221912430757962: { + heart: { + createdAt: '2023-08-25 12:37:45', + users: { + 12883048: { + skinTones: { + '-1': '2023-08-25 12:37:45', + }, + }, + }, + }, + }, + }, + }, + { + onyxMethod: 'merge', + key: 'personalDetailsList', + value: { + 14567013: { + accountID: 14567013, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', + displayName: 'Terry Hightraffic1337', + firstName: 'Terry', + lastName: 'Hightraffic1337', + status: null, + login: 'terry+hightraffic@margelo.io', + pronouns: '', + timezone: { + automatic: true, + selected: 'Europe/Kiev', + }, + payPalMeAddress: '', + phoneNumber: '', + validated: true, }, }, }, ], jsonCode: 200, - requestID: '783ef80a3fc5969a-SJC', + requestID: '81b8b8509a7f5b54-VIE', }); diff --git a/src/libs/E2E/apiMocks/readNewestAction.js b/src/libs/E2E/apiMocks/readNewestAction.js new file mode 100644 index 000000000000..04270a8d93f4 --- /dev/null +++ b/src/libs/E2E/apiMocks/readNewestAction.js @@ -0,0 +1,13 @@ +export default () => ({ + jsonCode: 200, + requestID: '81b8c48e3bfe5a84-VIE', + onyxData: [ + { + onyxMethod: 'merge', + key: 'report_98345625', + value: { + lastReadTime: '2023-10-25 07:32:48.915', + }, + }, + ], +}); diff --git a/src/libs/E2E/client.js b/src/libs/E2E/client.js index 7e6932d9fce5..59f7d7588fd5 100644 --- a/src/libs/E2E/client.js +++ b/src/libs/E2E/client.js @@ -10,19 +10,20 @@ const SERVER_ADDRESS = `http://localhost:${Config.SERVER_PORT}`; * @param {TestResult} testResult * @returns {Promise} */ -const submitTestResults = (testResult) => - fetch(`${SERVER_ADDRESS}${Routes.testResults}`, { +const submitTestResults = (testResult) => { + console.debug(`[E2E] Submitting test result '${testResult.name}'…`); + return fetch(`${SERVER_ADDRESS}${Routes.testResults}`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(testResult), }).then((res) => { - if (res.statusCode === 200) { + if (res.status === 200) { console.debug(`[E2E] Test result '${testResult.name}' submitted successfully`); return; } - const errorMsg = `Test result submission failed with status code ${res.statusCode}`; + const errorMsg = `Test result submission failed with status code ${res.status}`; res.json() .then((responseText) => { throw new Error(`${errorMsg}: ${responseText}`); @@ -31,19 +32,49 @@ const submitTestResults = (testResult) => throw new Error(errorMsg); }); }); +}; const submitTestDone = () => fetch(`${SERVER_ADDRESS}${Routes.testDone}`); +let currentActiveTestConfig = null; /** * @returns {Promise} */ const getTestConfig = () => fetch(`${SERVER_ADDRESS}${Routes.testConfig}`) .then((res) => res.json()) - .then((config) => config); + .then((config) => { + currentActiveTestConfig = config; + return config; + }); + +const getCurrentActiveTestConfig = () => currentActiveTestConfig; + +const sendNativeCommand = (payload) => + fetch(`${SERVER_ADDRESS}${Routes.testNativeCommand}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }).then((res) => { + if (res.status === 200) { + return true; + } + const errorMsg = `Sending native command failed with status code ${res.status}`; + res.json() + .then((responseText) => { + throw new Error(`${errorMsg}: ${responseText}`); + }) + .catch(() => { + throw new Error(errorMsg); + }); + }); export default { submitTestResults, submitTestDone, getTestConfig, + getCurrentActiveTestConfig, + sendNativeCommand, }; diff --git a/src/libs/E2E/reactNativeLaunchingTest.js b/src/libs/E2E/reactNativeLaunchingTest.js index 7621e462f8c5..f9ff4383f86d 100644 --- a/src/libs/E2E/reactNativeLaunchingTest.js +++ b/src/libs/E2E/reactNativeLaunchingTest.js @@ -23,6 +23,7 @@ if (!Metrics.canCapturePerformanceMetrics()) { const tests = { [E2EConfig.TEST_NAMES.AppStartTime]: require('./tests/appStartTimeTest.e2e').default, [E2EConfig.TEST_NAMES.OpenSearchPage]: require('./tests/openSearchPageTest.e2e').default, + [E2EConfig.TEST_NAMES.ReportTyping]: require('./tests/reportTypingTest.e2e').default, }; // Once we receive the TII measurement we know that the app is initialized and ready to be used: diff --git a/src/libs/E2E/tests/reportTypingTest.e2e.js b/src/libs/E2E/tests/reportTypingTest.e2e.js new file mode 100644 index 000000000000..b79166063b4f --- /dev/null +++ b/src/libs/E2E/tests/reportTypingTest.e2e.js @@ -0,0 +1,59 @@ +import E2ELogin from '@libs/E2E/actions/e2eLogin'; +import waitForKeyboard from '@libs/E2E/actions/waitForKeyboard'; +import E2EClient from '@libs/E2E/client'; +import Navigation from '@libs/Navigation/Navigation'; +import Performance from '@libs/Performance'; +import {getRerenderCount, resetRerenderCount} from '@pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.e2e'; +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import * as NativeCommands from '../../../../tests/e2e/nativeCommands/NativeCommandsAction'; + +const test = () => { + // check for login (if already logged in the action will simply resolve) + console.debug('[E2E] Logging in for typing'); + + E2ELogin().then((neededLogin) => { + if (neededLogin) { + // we don't want to submit the first login to the results + return E2EClient.submitTestDone(); + } + + console.debug('[E2E] Logged in, getting typing metrics and submitting them…'); + + Performance.subscribeToMeasurements((entry) => { + if (entry.name !== CONST.TIMING.SIDEBAR_LOADED) { + return; + } + + console.debug(`[E2E] Sidebar loaded, navigating to a report…`); + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute('98345625')); + + // Wait until keyboard is visible (so we are focused on the input): + waitForKeyboard().then(() => { + console.debug(`[E2E] Keyboard visible, typing…`); + E2EClient.sendNativeCommand(NativeCommands.makeBackspaceCommand()) + .then(() => { + resetRerenderCount(); + return Promise.resolve(); + }) + .then(() => E2EClient.sendNativeCommand(NativeCommands.makeTypeTextCommand('A'))) + .then(() => { + setTimeout(() => { + const rerenderCount = getRerenderCount(); + + E2EClient.submitTestResults({ + name: 'Composer typing rerender count', + renderCount: rerenderCount, + }).then(E2EClient.submitTestDone); + }, 3000); + }) + .catch((error) => { + console.error('[E2E] Error while test', error); + E2EClient.submitTestDone(); + }); + }); + }); + }); +}; + +export default test; diff --git a/src/libs/ErrorUtils.ts b/src/libs/ErrorUtils.ts index 99cd8f34b1e7..5bc8ea1d3508 100644 --- a/src/libs/ErrorUtils.ts +++ b/src/libs/ErrorUtils.ts @@ -1,5 +1,5 @@ import CONST from '@src/CONST'; -import {TranslationFlatObject} from '@src/languages/types'; +import {TranslationFlatObject, TranslationPaths} from '@src/languages/types'; import {ErrorFields, Errors} from '@src/types/onyx/OnyxCommon'; import Response from '@src/types/onyx/Response'; import DateUtils from './DateUtils'; @@ -93,7 +93,7 @@ type ErrorsList = Record; * @param errorList - An object containing current errors in the form * @param message - Message to assign to the inputID errors */ -function addErrorMessage(errors: ErrorsList, inputID?: string, message?: string) { +function addErrorMessage(errors: ErrorsList, inputID?: string, message?: TKey) { if (!message || !inputID) { return; } diff --git a/src/libs/Localize/LocaleListener/BaseLocaleListener.js b/src/libs/Localize/LocaleListener/BaseLocaleListener.ts similarity index 72% rename from src/libs/Localize/LocaleListener/BaseLocaleListener.js rename to src/libs/Localize/LocaleListener/BaseLocaleListener.ts index 0f861b20040a..c5eba18af422 100644 --- a/src/libs/Localize/LocaleListener/BaseLocaleListener.js +++ b/src/libs/Localize/LocaleListener/BaseLocaleListener.ts @@ -1,15 +1,14 @@ import Onyx from 'react-native-onyx'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import BaseLocale, {LocaleListenerConnect} from './types'; -let preferredLocale = CONST.LOCALES.DEFAULT; +let preferredLocale: BaseLocale = CONST.LOCALES.DEFAULT; /** * Adds event listener for changes to the locale. Callbacks are executed when the locale changes in Onyx. - * - * @param {Function} [callbackAfterChange] */ -const connect = (callbackAfterChange = () => {}) => { +const connect: LocaleListenerConnect = (callbackAfterChange = () => {}) => { Onyx.connect({ key: ONYXKEYS.NVP_PREFERRED_LOCALE, callback: (val) => { @@ -23,10 +22,7 @@ const connect = (callbackAfterChange = () => {}) => { }); }; -/* - * @return {String} - */ -function getPreferredLocale() { +function getPreferredLocale(): BaseLocale { return preferredLocale; } diff --git a/src/libs/Localize/LocaleListener/index.desktop.js b/src/libs/Localize/LocaleListener/index.desktop.js deleted file mode 100644 index 0c0d723122da..000000000000 --- a/src/libs/Localize/LocaleListener/index.desktop.js +++ /dev/null @@ -1,13 +0,0 @@ -import ELECTRON_EVENTS from '../../../../desktop/ELECTRON_EVENTS'; -import BaseLocaleListener from './BaseLocaleListener'; - -export default { - connect: (callbackAfterChange = () => {}) => - BaseLocaleListener.connect((val) => { - // Send the updated locale to the Electron main process - window.electron.send(ELECTRON_EVENTS.LOCALE_UPDATED, val); - - // Then execute the callback provided for the renderer process - callbackAfterChange(val); - }), -}; diff --git a/src/libs/Localize/LocaleListener/index.desktop.ts b/src/libs/Localize/LocaleListener/index.desktop.ts new file mode 100644 index 000000000000..6974d3ed4879 --- /dev/null +++ b/src/libs/Localize/LocaleListener/index.desktop.ts @@ -0,0 +1,18 @@ +import ELECTRON_EVENTS from '../../../../desktop/ELECTRON_EVENTS'; +import BaseLocaleListener from './BaseLocaleListener'; +import {LocaleListener, LocaleListenerConnect} from './types'; + +const localeListenerConnect: LocaleListenerConnect = (callbackAfterChange = () => {}) => + BaseLocaleListener.connect((val) => { + // Send the updated locale to the Electron main process + window.electron.send(ELECTRON_EVENTS.LOCALE_UPDATED, val); + + // Then execute the callback provided for the renderer process + callbackAfterChange(val); + }); + +const localeListener: LocaleListener = { + connect: localeListenerConnect, +}; + +export default localeListener; diff --git a/src/libs/Localize/LocaleListener/index.js b/src/libs/Localize/LocaleListener/index.js deleted file mode 100644 index e5f1ea03f93f..000000000000 --- a/src/libs/Localize/LocaleListener/index.js +++ /dev/null @@ -1,5 +0,0 @@ -import BaseLocaleListener from './BaseLocaleListener'; - -export default { - connect: BaseLocaleListener.connect, -}; diff --git a/src/libs/Localize/LocaleListener/index.ts b/src/libs/Localize/LocaleListener/index.ts new file mode 100644 index 000000000000..b0dda5d5fabc --- /dev/null +++ b/src/libs/Localize/LocaleListener/index.ts @@ -0,0 +1,10 @@ +import BaseLocaleListener from './BaseLocaleListener'; +import {LocaleListener, LocaleListenerConnect} from './types'; + +const localeListenerConnect: LocaleListenerConnect = BaseLocaleListener.connect; + +const localizeListener: LocaleListener = { + connect: localeListenerConnect, +}; + +export default localizeListener; diff --git a/src/libs/Localize/LocaleListener/types.ts b/src/libs/Localize/LocaleListener/types.ts new file mode 100644 index 000000000000..4daf90af0483 --- /dev/null +++ b/src/libs/Localize/LocaleListener/types.ts @@ -0,0 +1,13 @@ +import {ValueOf} from 'type-fest'; +import CONST from '@src/CONST'; + +type BaseLocale = ValueOf; + +type LocaleListenerConnect = (callbackAfterChange?: (locale?: BaseLocale) => void) => void; + +type LocaleListener = { + connect: LocaleListenerConnect; +}; + +export type {LocaleListenerConnect, LocaleListener}; +export default BaseLocale; diff --git a/src/libs/Localize/index.js b/src/libs/Localize/index.ts similarity index 52% rename from src/libs/Localize/index.js rename to src/libs/Localize/index.ts index f2f8cfa1f8b0..fd49902af369 100644 --- a/src/libs/Localize/index.js +++ b/src/libs/Localize/index.ts @@ -1,12 +1,10 @@ -import Str from 'expensify-common/lib/str'; -import lodashGet from 'lodash/get'; import * as RNLocalize from 'react-native-localize'; import Onyx from 'react-native-onyx'; -import _ from 'underscore'; import Log from '@libs/Log'; import Config from '@src/CONFIG'; import CONST from '@src/CONST'; import translations from '@src/languages/translations'; +import {TranslationFlatObject, TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import LocaleListener from './LocaleListener'; import BaseLocaleListener from './LocaleListener/BaseLocaleListener'; @@ -15,12 +13,11 @@ import BaseLocaleListener from './LocaleListener/BaseLocaleListener'; let userEmail = ''; Onyx.connect({ key: ONYXKEYS.SESSION, - waitForCollectionCallback: true, callback: (val) => { if (!val) { return; } - userEmail = val.email; + userEmail = val?.email ?? ''; }, }); @@ -29,66 +26,60 @@ LocaleListener.connect(); // Note: This has to be initialized inside a function and not at the top level of the file, because Intl is polyfilled, // and if React Native executes this code upon import, then the polyfill will not be available yet and it will barf -let CONJUNCTION_LIST_FORMATS_FOR_LOCALES; +let CONJUNCTION_LIST_FORMATS_FOR_LOCALES: Record; function init() { - CONJUNCTION_LIST_FORMATS_FOR_LOCALES = _.reduce( - CONST.LOCALES, - (memo, locale) => { - // This is not a supported locale, so we'll use ES_ES instead - if (locale === CONST.LOCALES.ES_ES_ONFIDO) { - // eslint-disable-next-line no-param-reassign - memo[locale] = new Intl.ListFormat(CONST.LOCALES.ES_ES, {style: 'long', type: 'conjunction'}); - return memo; - } - + CONJUNCTION_LIST_FORMATS_FOR_LOCALES = Object.values(CONST.LOCALES).reduce((memo: Record, locale) => { + // This is not a supported locale, so we'll use ES_ES instead + if (locale === CONST.LOCALES.ES_ES_ONFIDO) { // eslint-disable-next-line no-param-reassign - memo[locale] = new Intl.ListFormat(locale, {style: 'long', type: 'conjunction'}); + memo[locale] = new Intl.ListFormat(CONST.LOCALES.ES_ES, {style: 'long', type: 'conjunction'}); return memo; - }, - {}, - ); + } + + // eslint-disable-next-line no-param-reassign + memo[locale] = new Intl.ListFormat(locale, {style: 'long', type: 'conjunction'}); + return memo; + }, {}); } +type PhraseParameters = T extends (...args: infer A) => string ? A : never[]; +type Phrase = TranslationFlatObject[TKey] extends (...args: infer A) => unknown ? (...args: A) => string : string; + /** * Return translated string for given locale and phrase * - * @param {String} [desiredLanguage] eg 'en', 'es-ES' - * @param {String} phraseKey - * @param {Object} [phraseParameters] Parameters to supply if the phrase is a template literal. - * @returns {String} + * @param [desiredLanguage] eg 'en', 'es-ES' + * @param [phraseParameters] Parameters to supply if the phrase is a template literal. */ -function translate(desiredLanguage = CONST.LOCALES.DEFAULT, phraseKey, phraseParameters = {}) { - const languageAbbreviation = desiredLanguage.substring(0, 2); - let translatedPhrase; - +function translate(desiredLanguage: 'en' | 'es' | 'es-ES' | 'es_ES', phraseKey: TKey, ...phraseParameters: PhraseParameters>): string { // Search phrase in full locale e.g. es-ES - const desiredLanguageDictionary = translations[desiredLanguage] || {}; - translatedPhrase = desiredLanguageDictionary[phraseKey]; + const language = desiredLanguage === CONST.LOCALES.ES_ES_ONFIDO ? CONST.LOCALES.ES_ES : desiredLanguage; + let translatedPhrase = translations?.[language]?.[phraseKey] as Phrase; if (translatedPhrase) { - return Str.result(translatedPhrase, phraseParameters); + return typeof translatedPhrase === 'function' ? translatedPhrase(...phraseParameters) : translatedPhrase; } // Phrase is not found in full locale, search it in fallback language e.g. es - const fallbackLanguageDictionary = translations[languageAbbreviation] || {}; - translatedPhrase = fallbackLanguageDictionary[phraseKey]; + const languageAbbreviation = desiredLanguage.substring(0, 2) as 'en' | 'es'; + translatedPhrase = translations?.[languageAbbreviation]?.[phraseKey] as Phrase; if (translatedPhrase) { - return Str.result(translatedPhrase, phraseParameters); + return typeof translatedPhrase === 'function' ? translatedPhrase(...phraseParameters) : translatedPhrase; } + if (languageAbbreviation !== CONST.LOCALES.DEFAULT) { Log.alert(`${phraseKey} was not found in the ${languageAbbreviation} locale`); } // Phrase is not translated, search it in default language (en) - const defaultLanguageDictionary = translations[CONST.LOCALES.DEFAULT] || {}; - translatedPhrase = defaultLanguageDictionary[phraseKey]; + translatedPhrase = translations?.[CONST.LOCALES.DEFAULT]?.[phraseKey] as Phrase; if (translatedPhrase) { - return Str.result(translatedPhrase, phraseParameters); + return typeof translatedPhrase === 'function' ? translatedPhrase(...phraseParameters) : translatedPhrase; } // Phrase is not found in default language, on production and staging log an alert to server // on development throw an error if (Config.IS_IN_PRODUCTION || Config.IS_IN_STAGING) { - const phraseString = _.isArray(phraseKey) ? phraseKey.join('.') : phraseKey; + const phraseString: string = Array.isArray(phraseKey) ? phraseKey.join('.') : phraseKey; Log.alert(`${phraseString} was not found in the en locale`); if (userEmail.includes(CONST.EMAIL.EXPENSIFY_EMAIL_DOMAIN)) { return CONST.MISSING_TRANSLATION; @@ -100,49 +91,38 @@ function translate(desiredLanguage = CONST.LOCALES.DEFAULT, phraseKey, phrasePar /** * Uses the locale in this file updated by the Onyx subscriber. - * - * @param {String|Array} phrase - * @param {Object} [variables] - * @returns {String} */ -function translateLocal(phrase, variables) { - return translate(BaseLocaleListener.getPreferredLocale(), phrase, variables); +function translateLocal(phrase: TKey, ...variables: PhraseParameters>) { + return translate(BaseLocaleListener.getPreferredLocale(), phrase, ...variables); } /** * Return translated string for given error. - * - * @param {String|Array} message - * @returns {String} */ -function translateIfPhraseKey(message) { - if (_.isEmpty(message)) { +function translateIfPhraseKey(message: string | [string, Record & {isTranslated?: true}] | []): string { + if (!message || (Array.isArray(message) && message.length === 0)) { return ''; } try { // check if error message has a variable parameter - const [phrase, variables] = _.isArray(message) ? message : [message]; + const [phrase, variables] = Array.isArray(message) ? message : [message]; // This condition checks if the error is already translated. For example, if there are multiple errors per input, we handle translation in ErrorUtils.addErrorMessage due to the inability to concatenate error keys. - - if (variables && variables.isTranslated) { + if (variables?.isTranslated) { return phrase; } - return translateLocal(phrase, variables); + return translateLocal(phrase as TranslationPaths, variables as never); } catch (error) { - return message; + return Array.isArray(message) ? message[0] : message; } } /** * Format an array into a string with comma and "and" ("a dog, a cat and a chicken") - * - * @param {Array} anArray - * @return {String} */ -function arrayToString(anArray) { +function arrayToString(anArray: string[]) { if (!CONJUNCTION_LIST_FORMATS_FOR_LOCALES) { init(); } @@ -152,11 +132,9 @@ function arrayToString(anArray) { /** * Returns the user device's preferred language. - * - * @return {String} */ -function getDevicePreferredLocale() { - return lodashGet(RNLocalize.findBestAvailableLanguage([CONST.LOCALES.EN, CONST.LOCALES.ES]), 'languageTag', CONST.LOCALES.DEFAULT); +function getDevicePreferredLocale(): string { + return RNLocalize.findBestAvailableLanguage([CONST.LOCALES.EN, CONST.LOCALES.ES])?.languageTag ?? CONST.LOCALES.DEFAULT; } export {translate, translateLocal, translateIfPhraseKey, arrayToString, getDevicePreferredLocale}; diff --git a/src/libs/Navigation/Navigation.js b/src/libs/Navigation/Navigation.js index ae13e2b07206..de6c4a64237b 100644 --- a/src/libs/Navigation/Navigation.js +++ b/src/libs/Navigation/Navigation.js @@ -96,8 +96,8 @@ function navigate(route = ROUTES.HOME, type) { /** * @param {String} fallbackRoute - Fallback route if pop/goBack action should, but is not possible within RHP - * @param {Bool} shouldEnforceFallback - Enforces navigation to fallback route - * @param {Bool} shouldPopToTop - Should we navigate to LHN on back press + * @param {Boolean} shouldEnforceFallback - Enforces navigation to fallback route + * @param {Boolean} shouldPopToTop - Should we navigate to LHN on back press */ function goBack(fallbackRoute, shouldEnforceFallback = false, shouldPopToTop = false) { if (!canNavigate('goBack')) { @@ -207,6 +207,14 @@ function getActiveRoute() { return ''; } +/** + * Returns the current active route without the URL params + * @returns {String} + */ +function getActiveRouteWithoutParams() { + return getActiveRoute().replace(/\?.*/, ''); +} + /** Returns the active route name from a state event from the navigationRef * @param {Object} event * @returns {String | undefined} @@ -270,6 +278,7 @@ export default { dismissModal, isActiveRoute, getActiveRoute, + getActiveRouteWithoutParams, goBack, isNavigationReady, setIsNavigationReady, diff --git a/src/libs/Navigation/linkingConfig.js b/src/libs/Navigation/linkingConfig.js index b2db1758f24b..c017e6c7664e 100644 --- a/src/libs/Navigation/linkingConfig.js +++ b/src/libs/Navigation/linkingConfig.js @@ -142,7 +142,7 @@ export default { exact: true, }, Settings_ContactMethods: { - path: ROUTES.SETTINGS_CONTACT_METHODS, + path: ROUTES.SETTINGS_CONTACT_METHODS.route, exact: true, }, Settings_ContactMethodDetails: { diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 11e11f549682..4a7a34617842 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -261,6 +261,11 @@ function isConsecutiveActionMadeByPreviousActor(reportActions: ReportAction[] | return false; } + // Do not group if one of previous / current action is report preview and another one is not report preview + if ((isReportPreviewAction(previousAction) && !isReportPreviewAction(currentAction)) || (isReportPreviewAction(currentAction) && !isReportPreviewAction(previousAction))) { + return false; + } + return currentAction.actorAccountID === previousAction.actorAccountID; } diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js index 1e3fc5297193..8d24d98b19e8 100644 --- a/src/libs/ReportUtils.js +++ b/src/libs/ReportUtils.js @@ -3301,6 +3301,7 @@ function shouldReportBeInOptionList(report, currentReportId, isInGSDMode, betas, (report.participantAccountIDs && report.participantAccountIDs.length === 0 && !isChatThread(report) && + !isPublicRoom(report) && !isUserCreatedPolicyRoom(report) && !isArchivedRoom(report) && !isMoneyRequestReport(report) && @@ -4156,6 +4157,17 @@ function shouldUseFullTitleToDisplay(report) { return isMoneyRequestReport(report) || isPolicyExpenseChat(report) || isChatRoom(report) || isChatThread(report) || isTaskReport(report); } +/** + * + * @param {String} type + * @param {String} policyID + * @returns {Object} + */ +function getRoom(type, policyID) { + const room = _.find(allReports, (report) => report && report.policyID === policyID && report.chatType === type && !isThread(report)); + return room; +} + export { getReportParticipantsTitle, isReportMessageAttachment, @@ -4315,4 +4327,5 @@ export { parseReportRouteParams, getReimbursementQueuedActionMessage, getPersonalDetailsForAccountID, + getRoom, }; diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index 80ed96d25d65..8905616d94ce 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -412,10 +412,21 @@ function getOptionData( const reportAction = lastReportActions?.[report.reportID]; if (result.isArchivedRoom) { const archiveReason = (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.CLOSED && reportAction?.originalMessage?.reason) || CONST.REPORT.ARCHIVE_REASON.DEFAULT; - lastMessageText = Localize.translate(preferredLocale, `reportArchiveReasons.${archiveReason}`, { - displayName: PersonalDetailsUtils.getDisplayNameOrDefault(lastActorDetails, 'displayName'), - policyName: ReportUtils.getPolicyName(report, false, policy), - }); + + switch (archiveReason) { + case CONST.REPORT.ARCHIVE_REASON.ACCOUNT_CLOSED: + case CONST.REPORT.ARCHIVE_REASON.REMOVED_FROM_POLICY: + case CONST.REPORT.ARCHIVE_REASON.POLICY_DELETED: { + lastMessageText = Localize.translate(preferredLocale, `reportArchiveReasons.${archiveReason}`, { + policyName: ReportUtils.getPolicyName(report, false, policy), + displayName: PersonalDetailsUtils.getDisplayNameOrDefault(lastActorDetails, 'displayName'), + }); + break; + } + default: { + lastMessageText = Localize.translate(preferredLocale, `reportArchiveReasons.default`); + } + } } if ((result.isChatRoom || result.isPolicyExpenseChat || result.isThread || result.isTaskReport) && !result.isArchivedRoom) { diff --git a/src/libs/actions/IOU.js b/src/libs/actions/IOU.js index 19ac03228753..acdbc200842b 100644 --- a/src/libs/actions/IOU.js +++ b/src/libs/actions/IOU.js @@ -2103,6 +2103,7 @@ function deleteMoneyRequest(transactionID, reportAction, isSingleTransactionView key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport.reportID}`, value: { hasOutstandingIOU: false, + hasOutstandingChildRequest: false, iouReportID: null, lastMessageText: ReportActionsUtils.getLastVisibleMessage(iouReport.chatReportID, {[reportPreviewAction.reportActionID]: null}).lastMessageText, lastVisibleActionCreated: lodashGet(ReportActionsUtils.getLastVisibleAction(iouReport.chatReportID, {[reportPreviewAction.reportActionID]: null}), 'created'), @@ -2449,6 +2450,7 @@ function getPayMoneyRequestParams(chatReport, iouReport, recipient, paymentMetho lastReadTime: DateUtils.getDBTime(), lastVisibleActionCreated: optimisticIOUReportAction.created, hasOutstandingIOU: false, + hasOutstandingChildRequest: false, iouReportID: null, lastMessageText: optimisticIOUReportAction.message[0].text, lastMessageHtml: optimisticIOUReportAction.message[0].html, @@ -2472,6 +2474,7 @@ function getPayMoneyRequestParams(chatReport, iouReport, recipient, paymentMetho lastMessageText: optimisticIOUReportAction.message[0].text, lastMessageHtml: optimisticIOUReportAction.message[0].html, hasOutstandingIOU: false, + hasOutstandingChildRequest: false, statusNum: CONST.REPORT.STATUS.REIMBURSED, }, }, diff --git a/src/libs/actions/Link.js b/src/libs/actions/Link.ts similarity index 65% rename from src/libs/actions/Link.js rename to src/libs/actions/Link.ts index 0a50bb62ddc8..d741ced6dc08 100644 --- a/src/libs/actions/Link.js +++ b/src/libs/actions/Link.ts @@ -1,6 +1,4 @@ -import lodashGet from 'lodash/get'; import Onyx from 'react-native-onyx'; -import _ from 'underscore'; import * as API from '@libs/API'; import asyncOpenURL from '@libs/asyncOpenURL'; import * as Environment from '@libs/Environment/Environment'; @@ -10,29 +8,23 @@ import ONYXKEYS from '@src/ONYXKEYS'; let isNetworkOffline = false; Onyx.connect({ key: ONYXKEYS.NETWORK, - callback: (val) => (isNetworkOffline = lodashGet(val, 'isOffline', false)), + callback: (value) => (isNetworkOffline = value?.isOffline ?? false), }); -let currentUserEmail; +let currentUserEmail = ''; Onyx.connect({ key: ONYXKEYS.SESSION, - callback: (val) => (currentUserEmail = lodashGet(val, 'email', '')), + callback: (value) => (currentUserEmail = value?.email ?? ''), }); -/** - * @param {String} [url] the url path - * @param {String} [shortLivedAuthToken] - * - * @returns {Promise} - */ -function buildOldDotURL(url, shortLivedAuthToken) { +function buildOldDotURL(url: string, shortLivedAuthToken?: string): Promise { const hasHashParams = url.indexOf('#') !== -1; const hasURLParams = url.indexOf('?') !== -1; const authTokenParam = shortLivedAuthToken ? `authToken=${shortLivedAuthToken}` : ''; const emailParam = `email=${encodeURIComponent(currentUserEmail)}`; - - const params = _.compact([authTokenParam, emailParam]).join('&'); + const paramsArray = [authTokenParam, emailParam]; + const params = paramsArray.filter(Boolean).join('&'); return Environment.getOldDotEnvironmentURL().then((environmentURL) => { const oldDotDomain = Url.addTrailingForwardSlash(environmentURL); @@ -43,17 +35,13 @@ function buildOldDotURL(url, shortLivedAuthToken) { } /** - * @param {String} url - * @param {Boolean} shouldSkipCustomSafariLogic When true, we will use `Linking.openURL` even if the browser is Safari. + * @param shouldSkipCustomSafariLogic When true, we will use `Linking.openURL` even if the browser is Safari. */ -function openExternalLink(url, shouldSkipCustomSafariLogic = false) { +function openExternalLink(url: string, shouldSkipCustomSafariLogic = false) { asyncOpenURL(Promise.resolve(), url, shouldSkipCustomSafariLogic); } -/** - * @param {String} url the url path - */ -function openOldDotLink(url) { +function openOldDotLink(url: string) { if (isNetworkOffline) { buildOldDotURL(url).then((oldDotURL) => openExternalLink(oldDotURL)); return; @@ -63,7 +51,7 @@ function openOldDotLink(url) { asyncOpenURL( // eslint-disable-next-line rulesdir/no-api-side-effects-method API.makeRequestWithSideEffects('OpenOldDotLink', {}, {}) - .then((response) => buildOldDotURL(url, response.shortLivedAuthToken)) + .then((response) => (response ? buildOldDotURL(url, response.shortLivedAuthToken) : buildOldDotURL(url))) .catch(() => buildOldDotURL(url)), (oldDotURL) => oldDotURL, ); diff --git a/src/libs/actions/PersonalDetails.js b/src/libs/actions/PersonalDetails.ts similarity index 56% rename from src/libs/actions/PersonalDetails.js rename to src/libs/actions/PersonalDetails.ts index 351943ca1f29..01f8c2f4916b 100644 --- a/src/libs/actions/PersonalDetails.js +++ b/src/libs/actions/PersonalDetails.ts @@ -1,32 +1,38 @@ import Str from 'expensify-common/lib/str'; -import lodashGet from 'lodash/get'; -import Onyx from 'react-native-onyx'; -import _ from 'underscore'; +import Onyx, {OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import * as API from '@libs/API'; +import {CustomRNImageManipulatorResult, FileWithUri} from '@libs/cropOrRotateImage/types'; import * as LocalePhoneNumber from '@libs/LocalePhoneNumber'; import Navigation from '@libs/Navigation/Navigation'; import * as UserUtils from '@libs/UserUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import {DateOfBirthForm, PersonalDetails, PrivatePersonalDetails} from '@src/types/onyx'; +import {Timezone} from '@src/types/onyx/PersonalDetails'; + +type FirstAndLastName = { + firstName: string; + lastName: string; +}; let currentUserEmail = ''; -let currentUserAccountID; +let currentUserAccountID = -1; Onyx.connect({ key: ONYXKEYS.SESSION, callback: (val) => { - currentUserEmail = val ? val.email : ''; - currentUserAccountID = val ? val.accountID : -1; + currentUserEmail = val?.email ?? ''; + currentUserAccountID = val?.accountID ?? -1; }, }); -let allPersonalDetails; +let allPersonalDetails: OnyxEntry> = null; Onyx.connect({ key: ONYXKEYS.PERSONAL_DETAILS_LIST, callback: (val) => (allPersonalDetails = val), }); -let privatePersonalDetails; +let privatePersonalDetails: OnyxEntry = null; Onyx.connect({ key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS, callback: (val) => (privatePersonalDetails = val), @@ -34,64 +40,60 @@ Onyx.connect({ /** * Returns the displayName for a user - * - * @param {String} login - * @param {Object} [personalDetail] - * @returns {String} */ -function getDisplayName(login, personalDetail) { +function getDisplayName(login: string, personalDetail: Pick | null): string { // If we have a number like +15857527441@expensify.sms then let's remove @expensify.sms and format it // so that the option looks cleaner in our UI. const userLogin = LocalePhoneNumber.formatPhoneNumber(login); - const userDetails = personalDetail || lodashGet(allPersonalDetails, login); + const userDetails = personalDetail ?? allPersonalDetails?.[login]; if (!userDetails) { return userLogin; } - const firstName = userDetails.firstName || ''; - const lastName = userDetails.lastName || ''; + const firstName = userDetails.firstName ?? ''; + const lastName = userDetails.lastName ?? ''; const fullName = `${firstName} ${lastName}`.trim(); + // It's possible for fullName to be empty string, so we must use "||" to fallback to userLogin. return fullName || userLogin; } /** - * @param {String} userAccountIDOrLogin - * @param {String} [defaultDisplayName] display name to use if user details don't exist in Onyx or if + * @param [defaultDisplayName] display name to use if user details don't exist in Onyx or if * found details don't include the user's displayName or login - * @returns {String} */ -function getDisplayNameForTypingIndicator(userAccountIDOrLogin, defaultDisplayName = '') { +function getDisplayNameForTypingIndicator(userAccountIDOrLogin: string, defaultDisplayName = ''): string { // Try to convert to a number, which means we have an accountID const accountID = Number(userAccountIDOrLogin); // If the user is typing on OldDot, userAccountIDOrLogin will be a string (the user's login), // so Number(string) is NaN. Search for personalDetails by login to get the display name. - if (_.isNaN(accountID)) { - const detailsByLogin = _.findWhere(allPersonalDetails, {login: userAccountIDOrLogin}) || {}; - return detailsByLogin.displayName || userAccountIDOrLogin; + if (Number.isNaN(accountID)) { + const detailsByLogin = Object.entries(allPersonalDetails ?? {}).find(([, value]) => value?.login === userAccountIDOrLogin)?.[1]; + + // It's possible for displayName to be empty string, so we must use "||" to fallback to userAccountIDOrLogin. + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + return detailsByLogin?.displayName || userAccountIDOrLogin; } - const detailsByAccountID = lodashGet(allPersonalDetails, accountID, {}); - return detailsByAccountID.displayName || detailsByAccountID.login || defaultDisplayName; + const detailsByAccountID = allPersonalDetails?.[accountID]; + + // It's possible for displayName to be empty string, so we must use "||" to fallback to login or defaultDisplayName. + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + return detailsByAccountID?.displayName || detailsByAccountID?.login || defaultDisplayName; } /** * Gets the first and last name from the user's personal details. * If the login is the same as the displayName, then they don't exist, * so we return empty strings instead. - * @param {Object} personalDetail - * @param {String} personalDetail.login - * @param {String} personalDetail.displayName - * @param {String} personalDetail.firstName - * @param {String} personalDetail.lastName - * - * @returns {Object} */ -function extractFirstAndLastNameFromAvailableDetails({login, displayName, firstName, lastName}) { +function extractFirstAndLastNameFromAvailableDetails({login, displayName, firstName, lastName}: PersonalDetails): FirstAndLastName { + // It's possible for firstName to be empty string, so we must use "||" to consider lastName instead. + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing if (firstName || lastName) { - return {firstName: firstName || '', lastName: lastName || ''}; + return {firstName: firstName ?? '', lastName: lastName ?? ''}; } if (login && Str.removeSMSDomain(login) === displayName) { return {firstName: '', lastName: ''}; @@ -112,24 +114,24 @@ function extractFirstAndLastNameFromAvailableDetails({login, displayName, firstN /** * Convert country names obtained from the backend to their respective ISO codes * This is for backward compatibility of stored data before E/App#15507 - * @param {String} countryName - * @returns {String} */ -function getCountryISO(countryName) { - if (_.isEmpty(countryName) || countryName.length === 2) { +function getCountryISO(countryName: string): string { + if (!countryName || countryName.length === 2) { return countryName; } - return _.findKey(CONST.ALL_COUNTRIES, (country) => country === countryName) || ''; + + return Object.entries(CONST.ALL_COUNTRIES).find(([, value]) => value === countryName)?.[0] ?? ''; } -/** - * @param {String} pronouns - */ -function updatePronouns(pronouns) { - API.write( - 'UpdatePronouns', - {pronouns}, - { +function updatePronouns(pronouns: string) { + if (currentUserAccountID) { + type UpdatePronounsParams = { + pronouns: string; + }; + + const parameters: UpdatePronounsParams = {pronouns}; + + API.write('UpdatePronouns', parameters, { optimisticData: [ { onyxMethod: Onyx.METHOD.MERGE, @@ -141,20 +143,22 @@ function updatePronouns(pronouns) { }, }, ], - }, - ); + }); + } + Navigation.goBack(ROUTES.SETTINGS_PROFILE); } -/** - * @param {String} firstName - * @param {String} lastName - */ -function updateDisplayName(firstName, lastName) { - API.write( - 'UpdateDisplayName', - {firstName, lastName}, - { +function updateDisplayName(firstName: string, lastName: string) { + if (currentUserAccountID) { + type UpdateDisplayNameParams = { + firstName: string; + lastName: string; + }; + + const parameters: UpdateDisplayNameParams = {firstName, lastName}; + + API.write('UpdateDisplayName', parameters, { optimisticData: [ { onyxMethod: Onyx.METHOD.MERGE, @@ -163,7 +167,7 @@ function updateDisplayName(firstName, lastName) { [currentUserAccountID]: { firstName, lastName, - displayName: getDisplayName(currentUserEmail, { + displayName: getDisplayName(currentUserEmail ?? '', { firstName, lastName, }), @@ -171,67 +175,73 @@ function updateDisplayName(firstName, lastName) { }, }, ], - }, - ); + }); + } + Navigation.goBack(ROUTES.SETTINGS_PROFILE); } -/** - * @param {String} legalFirstName - * @param {String} legalLastName - */ -function updateLegalName(legalFirstName, legalLastName) { - API.write( - 'UpdateLegalName', - {legalFirstName, legalLastName}, - { - optimisticData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS, - value: { - legalFirstName, - legalLastName, - }, +function updateLegalName(legalFirstName: string, legalLastName: string) { + type UpdateLegalNameParams = { + legalFirstName: string; + legalLastName: string; + }; + + const parameters: UpdateLegalNameParams = {legalFirstName, legalLastName}; + + API.write('UpdateLegalName', parameters, { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS, + value: { + legalFirstName, + legalLastName, }, - ], - }, - ); + }, + ], + }); + Navigation.goBack(ROUTES.SETTINGS_PERSONAL_DETAILS); } /** - * @param {String} dob - date of birth + * @param dob - date of birth */ -function updateDateOfBirth({dob}) { - API.write( - 'UpdateDateOfBirth', - {dob}, - { - optimisticData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS, - value: { - dob, - }, +function updateDateOfBirth({dob}: DateOfBirthForm) { + type UpdateDateOfBirthParams = { + dob?: string; + }; + + const parameters: UpdateDateOfBirthParams = {dob}; + + API.write('UpdateDateOfBirth', parameters, { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS, + value: { + dob, }, - ], - }, - ); + }, + ], + }); + Navigation.goBack(ROUTES.SETTINGS_PERSONAL_DETAILS); } -/** - * @param {String} street - * @param {String} street2 - * @param {String} city - * @param {String} state - * @param {String} zip - * @param {String} country - */ -function updateAddress(street, street2, city, state, zip, country) { - const parameters = { +function updateAddress(street: string, street2: string, city: string, state: string, zip: string, country: string) { + type UpdateHomeAddressParams = { + homeAddressStreet: string; + addressStreet2: string; + homeAddressCity: string; + addressState: string; + addressZipCode: string; + addressCountry: string; + addressStateLong?: string; + }; + + const parameters: UpdateHomeAddressParams = { homeAddressStreet: street, addressStreet2: street2, homeAddressCity: city, @@ -245,6 +255,7 @@ function updateAddress(street, street2, city, state, zip, country) { if (country !== CONST.COUNTRY.US) { parameters.addressStateLong = state; } + API.write('UpdateHomeAddress', parameters, { optimisticData: [ { @@ -262,55 +273,61 @@ function updateAddress(street, street2, city, state, zip, country) { }, ], }); + Navigation.goBack(ROUTES.SETTINGS_PERSONAL_DETAILS); } /** * Updates timezone's 'automatic' setting, and updates * selected timezone if set to automatically update. - * - * @param {Object} timezone - * @param {Boolean} timezone.automatic - * @param {String} timezone.selected */ -function updateAutomaticTimezone(timezone) { - API.write( - 'UpdateAutomaticTimezone', - { - timezone: JSON.stringify(timezone), - }, - { - optimisticData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.PERSONAL_DETAILS_LIST, - value: { - [currentUserAccountID]: { - timezone, - }, +function updateAutomaticTimezone(timezone: Timezone) { + if (!currentUserAccountID) { + return; + } + + type UpdateAutomaticTimezoneParams = { + timezone: string; + }; + + const parameters: UpdateAutomaticTimezoneParams = { + timezone: JSON.stringify(timezone), + }; + + API.write('UpdateAutomaticTimezone', parameters, { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + value: { + [currentUserAccountID]: { + timezone, }, }, - ], - }, - ); + }, + ], + }); } /** * Updates user's 'selected' timezone, then navigates to the * initial Timezone page. - * - * @param {String} selectedTimezone */ -function updateSelectedTimezone(selectedTimezone) { - const timezone = { +function updateSelectedTimezone(selectedTimezone: string) { + const timezone: Timezone = { selected: selectedTimezone, }; - API.write( - 'UpdateSelectedTimezone', - { - timezone: JSON.stringify(timezone), - }, - { + + type UpdateSelectedTimezoneParams = { + timezone: string; + }; + + const parameters: UpdateSelectedTimezoneParams = { + timezone: JSON.stringify(timezone), + }; + + if (currentUserAccountID) { + API.write('UpdateSelectedTimezone', parameters, { optimisticData: [ { onyxMethod: Onyx.METHOD.MERGE, @@ -322,8 +339,9 @@ function updateSelectedTimezone(selectedTimezone) { }, }, ], - }, - ); + }); + } + Navigation.goBack(ROUTES.SETTINGS_TIMEZONE); } @@ -331,7 +349,7 @@ function updateSelectedTimezone(selectedTimezone) { * Fetches additional personal data like legal name, date of birth, address */ function openPersonalDetailsPage() { - const optimisticData = [ + const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS, @@ -341,7 +359,7 @@ function openPersonalDetailsPage() { }, ]; - const successData = [ + const successData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS, @@ -351,7 +369,7 @@ function openPersonalDetailsPage() { }, ]; - const failureData = [ + const failureData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS, @@ -361,17 +379,20 @@ function openPersonalDetailsPage() { }, ]; - API.read('OpenPersonalDetailsPage', {}, {optimisticData, successData, failureData}); + type OpenPersonalDetailsPageParams = Record; + + const parameters: OpenPersonalDetailsPageParams = {}; + + API.read('OpenPersonalDetailsPage', parameters, {optimisticData, successData, failureData}); } /** * Fetches public profile info about a given user. * The API will only return the accountID, displayName, and avatar for the user * but the profile page will use other info (e.g. contact methods and pronouns) if they are already available in Onyx - * @param {Number} accountID */ -function openPublicProfilePage(accountID) { - const optimisticData = [ +function openPublicProfilePage(accountID: number) { + const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.PERSONAL_DETAILS_LIST, @@ -382,7 +403,8 @@ function openPublicProfilePage(accountID) { }, }, ]; - const successData = [ + + const successData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.PERSONAL_DETAILS_LIST, @@ -393,7 +415,8 @@ function openPublicProfilePage(accountID) { }, }, ]; - const failureData = [ + + const failureData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.PERSONAL_DETAILS_LIST, @@ -404,16 +427,25 @@ function openPublicProfilePage(accountID) { }, }, ]; - API.read('OpenPublicProfilePage', {accountID}, {optimisticData, successData, failureData}); + + type OpenPublicProfilePageParams = { + accountID: number; + }; + + const parameters: OpenPublicProfilePageParams = {accountID}; + + API.read('OpenPublicProfilePage', parameters, {optimisticData, successData, failureData}); } /** * Updates the user's avatar image - * - * @param {File|Object} file */ -function updateAvatar(file) { - const optimisticData = [ +function updateAvatar(file: FileWithUri | CustomRNImageManipulatorResult) { + if (!currentUserAccountID) { + return; + } + + const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.PERSONAL_DETAILS_LIST, @@ -434,7 +466,7 @@ function updateAvatar(file) { }, }, ]; - const successData = [ + const successData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.PERSONAL_DETAILS_LIST, @@ -447,14 +479,14 @@ function updateAvatar(file) { }, }, ]; - const failureData = [ + const failureData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.PERSONAL_DETAILS_LIST, value: { [currentUserAccountID]: { - avatar: allPersonalDetails[currentUserAccountID].avatar, - avatarThumbnail: allPersonalDetails[currentUserAccountID].avatarThumbnail || allPersonalDetails[currentUserAccountID].avatar, + avatar: allPersonalDetails?.[currentUserAccountID]?.avatar, + avatarThumbnail: allPersonalDetails?.[currentUserAccountID]?.avatarThumbnail ?? allPersonalDetails?.[currentUserAccountID]?.avatar, pendingFields: { avatar: null, }, @@ -463,17 +495,27 @@ function updateAvatar(file) { }, ]; - API.write('UpdateUserAvatar', {file}, {optimisticData, successData, failureData}); + type UpdateUserAvatarParams = { + file: FileWithUri | CustomRNImageManipulatorResult; + }; + + const parameters: UpdateUserAvatarParams = {file}; + + API.write('UpdateUserAvatar', parameters, {optimisticData, successData, failureData}); } /** * Replaces the user's avatar image with a default avatar */ function deleteAvatar() { + if (!currentUserAccountID) { + return; + } + // We want to use the old dot avatar here as this affects both platforms. const defaultAvatar = UserUtils.getDefaultAvatarURL(currentUserAccountID); - const optimisticData = [ + const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.PERSONAL_DETAILS_LIST, @@ -485,26 +527,34 @@ function deleteAvatar() { }, }, ]; - const failureData = [ + const failureData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.PERSONAL_DETAILS_LIST, value: { [currentUserAccountID]: { - avatar: allPersonalDetails[currentUserAccountID].avatar, - fallbackIcon: allPersonalDetails[currentUserAccountID].fallbackIcon, + avatar: allPersonalDetails?.[currentUserAccountID]?.avatar, + fallbackIcon: allPersonalDetails?.[currentUserAccountID]?.fallbackIcon, }, }, }, ]; - API.write('DeleteUserAvatar', {}, {optimisticData, failureData}); + type DeleteUserAvatarParams = Record; + + const parameters: DeleteUserAvatarParams = {}; + + API.write('DeleteUserAvatar', parameters, {optimisticData, failureData}); } /** * Clear error and pending fields for the current user's avatar */ function clearAvatarErrors() { + if (!currentUserAccountID) { + return; + } + Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, { [currentUserAccountID]: { errorFields: { @@ -519,28 +569,27 @@ function clearAvatarErrors() { /** * Get private personal details value - * @returns {Object} */ -function getPrivatePersonalDetails() { +function getPrivatePersonalDetails(): OnyxEntry { return privatePersonalDetails; } export { + clearAvatarErrors, + deleteAvatar, + extractFirstAndLastNameFromAvailableDetails, + getCountryISO, getDisplayName, getDisplayNameForTypingIndicator, - updateAvatar, - deleteAvatar, + getPrivatePersonalDetails, openPersonalDetailsPage, openPublicProfilePage, - extractFirstAndLastNameFromAvailableDetails, + updateAddress, + updateAutomaticTimezone, + updateAvatar, + updateDateOfBirth, updateDisplayName, updateLegalName, - updateDateOfBirth, - updateAddress, updatePronouns, - clearAvatarErrors, - updateAutomaticTimezone, updateSelectedTimezone, - getCountryISO, - getPrivatePersonalDetails, }; diff --git a/src/libs/actions/Policy.js b/src/libs/actions/Policy.js index 9b33ff9b086e..bf064d8bf6d8 100644 --- a/src/libs/actions/Policy.js +++ b/src/libs/actions/Policy.js @@ -240,6 +240,69 @@ function isAdminOfFreePolicy(policies) { return _.some(policies, (policy) => policy && policy.type === CONST.POLICY.TYPE.FREE && policy.role === CONST.POLICY.ROLE.ADMIN); } +/** + * Build optimistic data for adding members to the announce room + * @param {String} policyID + * @param {Array} accountIDs + * @returns {Object} + */ +function buildAnnounceRoomMembersOnyxData(policyID, accountIDs) { + const announceReport = ReportUtils.getRoom(CONST.REPORT.CHAT_TYPE.POLICY_ANNOUNCE, policyID); + const announceRoomMembers = { + onyxOptimisticData: [], + onyxFailureData: [], + }; + + announceRoomMembers.onyxOptimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${announceReport.reportID}`, + value: { + participantAccountIDs: [...announceReport.participantAccountIDs, ...accountIDs], + }, + }); + + announceRoomMembers.onyxFailureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${announceReport.reportID}`, + value: { + participantAccountIDs: announceReport.participantAccountIDs, + }, + }); + return announceRoomMembers; +} + +/** + * Build optimistic data for removing users from the announce room + * @param {String} policyID + * @param {Array} accountIDs + * @returns {Object} + */ +function removeOptimisticAnnounceRoomMembers(policyID, accountIDs) { + const announceReport = ReportUtils.getRoom(CONST.REPORT.CHAT_TYPE.POLICY_ANNOUNCE, policyID); + const announceRoomMembers = { + onyxOptimisticData: [], + onyxFailureData: [], + }; + + const remainUsers = _.difference(announceReport.participantAccountIDs, accountIDs); + announceRoomMembers.onyxOptimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${announceReport.reportID}`, + value: { + participantAccountIDs: [...remainUsers], + }, + }); + + announceRoomMembers.onyxFailureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${announceReport.reportID}`, + value: { + participantAccountIDs: announceReport.participantAccountIDs, + }, + }); + return announceRoomMembers; +} + /** * Remove the passed members from the policy employeeList * @@ -260,6 +323,8 @@ function removeMembers(accountIDs, policyID) { ReportUtils.buildOptimisticClosedReportAction(sessionEmail, policy.name, CONST.REPORT.ARCHIVE_REASON.REMOVED_FROM_POLICY), ); + const announceRoomMembers = removeOptimisticAnnounceRoomMembers(policyID, accountIDs); + const optimisticData = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -281,6 +346,7 @@ function removeMembers(accountIDs, policyID) { key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${workspaceChats[index].reportID}`, value: {[reportAction.reportActionID]: reportAction}, })), + ...announceRoomMembers.onyxOptimisticData, ]; // If the policy has primaryLoginsInvited, then it displays informative messages on the members page about which primary logins were added by secondary logins. @@ -332,6 +398,7 @@ function removeMembers(accountIDs, policyID) { key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${workspaceChats[index].reportID}`, value: {[reportAction.reportActionID]: null}, })), + ...announceRoomMembers.onyxFailureData, ]; API.write( 'DeleteMembersFromWorkspace', @@ -447,6 +514,8 @@ function addMembersToWorkspace(invitedEmailsToAccountIDs, welcomeNote, policyID) const accountIDs = _.values(invitedEmailsToAccountIDs); const newPersonalDetailsOnyxData = PersonalDetailsUtils.getNewPersonalDetailsOnyxData(logins, accountIDs); + const announceRoomMembers = buildAnnounceRoomMembersOnyxData(policyID, accountIDs); + // create onyx data for policy expense chats for each new member const membersChats = createPolicyExpenseChats(policyID, invitedEmailsToAccountIDs); @@ -460,6 +529,7 @@ function addMembersToWorkspace(invitedEmailsToAccountIDs, welcomeNote, policyID) }, ...newPersonalDetailsOnyxData.optimisticData, ...membersChats.onyxOptimisticData, + ...announceRoomMembers.onyxOptimisticData, ]; const successData = [ @@ -507,6 +577,7 @@ function addMembersToWorkspace(invitedEmailsToAccountIDs, welcomeNote, policyID) }, ...newPersonalDetailsOnyxData.failureData, ...membersChats.onyxFailureData, + ...announceRoomMembers.onyxFailureData, ]; const params = { diff --git a/src/libs/actions/ReportActions.ts b/src/libs/actions/ReportActions.ts index b9cea498a3fa..d7ff96fc6c2e 100644 --- a/src/libs/actions/ReportActions.ts +++ b/src/libs/actions/ReportActions.ts @@ -4,6 +4,7 @@ import * as ReportUtils from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ReportAction from '@src/types/onyx/ReportAction'; +import * as Report from './Report'; function clearReportActionErrors(reportID: string, reportAction: ReportAction) { const originalReportID = ReportUtils.getOriginalReportID(reportID, reportAction); @@ -24,6 +25,11 @@ function clearReportActionErrors(reportID: string, reportAction: ReportAction) { Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${linkedTransactionID}`, null); } + // Delete the failed task report too + const taskReportID = reportAction.message?.[0]?.taskReportID; + if (taskReportID) { + Report.deleteReport(taskReportID); + } return; } diff --git a/src/libs/actions/Task.js b/src/libs/actions/Task.js index 959710967881..e884a4d7a6b3 100644 --- a/src/libs/actions/Task.js +++ b/src/libs/actions/Task.js @@ -15,6 +15,7 @@ import * as UserUtils from '@libs/UserUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import * as Report from './Report'; let currentUserEmail; let currentUserAccountID; @@ -134,9 +135,13 @@ function createTaskAndNavigate(parentReportID, title, description, assigneeEmail // FOR TASK REPORT const failureData = [ { - onyxMethod: Onyx.METHOD.SET, + onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${optimisticTaskReport.reportID}`, - value: null, + value: { + errorFields: { + createTask: ErrorUtils.getMicroSecondOnyxError('task.genericCreateTaskFailureMessage'), + }, + }, }, { onyxMethod: Onyx.METHOD.MERGE, @@ -186,7 +191,11 @@ function createTaskAndNavigate(parentReportID, title, description, assigneeEmail failureData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`, - value: {[optimisticAddCommentReport.reportAction.reportActionID]: {pendingAction: null}}, + value: { + [optimisticAddCommentReport.reportAction.reportActionID]: { + errors: ErrorUtils.getMicroSecondOnyxError('task.genericCreateTaskFailureMessage'), + }, + }, }); clearOutTaskInfo(); @@ -879,7 +888,19 @@ function canModifyTask(taskReport, sessionAccountID) { /** * @param {String} reportID */ -function clearEditTaskErrors(reportID) { +function clearTaskErrors(reportID) { + const report = ReportUtils.getReport(reportID); + + // Delete the task preview in the parent report + if (lodashGet(report, 'pendingFields.createChat') === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD) { + Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`, { + [report.parentReportActionID]: null, + }); + + Report.navigateToConciergeChatAndDeleteReport(reportID); + return; + } + Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, { pendingFields: null, errorFields: null, @@ -934,7 +955,7 @@ export { cancelTask, dismissModalAndClearOutTaskInfo, getTaskAssigneeAccountID, - clearEditTaskErrors, + clearTaskErrors, canModifyTask, getTaskReportActionMessage, }; diff --git a/src/libs/actions/User.js b/src/libs/actions/User.js index f7375a5583a6..3c91dc4624cd 100644 --- a/src/libs/actions/User.js +++ b/src/libs/actions/User.js @@ -238,7 +238,7 @@ function deleteContactMethod(contactMethod, loginList) { }, {optimisticData, successData, failureData}, ); - Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS); + Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.route); } /** @@ -328,7 +328,7 @@ function addNewContactMethodAndNavigate(contactMethod) { ]; API.write('AddNewContactMethod', {partnerUserID: contactMethod}, {optimisticData, successData, failureData}); - Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS); + Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.route); } /** @@ -755,7 +755,7 @@ function setContactMethodAsDefault(newDefaultContactMethod) { }, ]; API.write('SetContactMethodAsDefault', {partnerUserID: newDefaultContactMethod}, {optimisticData, successData, failureData}); - Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS); + Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.route); } /** diff --git a/src/libs/cropOrRotateImage/types.ts b/src/libs/cropOrRotateImage/types.ts index 6abbdab49ea5..09f441bd9324 100644 --- a/src/libs/cropOrRotateImage/types.ts +++ b/src/libs/cropOrRotateImage/types.ts @@ -26,4 +26,4 @@ type CustomRNImageManipulatorResult = RNImageManipulatorResult & {size: number; type CropOrRotateImage = (uri: string, actions: Action[], options: CropOrRotateImageOptions) => Promise; -export type {CropOrRotateImage, CropOptions, Action, FileWithUri, CropOrRotateImageOptions}; +export type {CropOrRotateImage, CropOptions, Action, FileWithUri, CropOrRotateImageOptions, CustomRNImageManipulatorResult}; diff --git a/src/libs/fileDownload/index.js b/src/libs/fileDownload/index.js index 0b86daf7141f..d1fa968b665f 100644 --- a/src/libs/fileDownload/index.js +++ b/src/libs/fileDownload/index.js @@ -1,3 +1,5 @@ +import * as ApiUtils from '@libs/ApiUtils'; +import tryResolveUrlFromApiRoot from '@libs/tryResolveUrlFromApiRoot'; import * as Link from '@userActions/Link'; import * as FileUtils from './FileUtils'; @@ -8,7 +10,15 @@ import * as FileUtils from './FileUtils'; * @returns {Promise} */ export default function fileDownload(url, fileName) { - return new Promise((resolve) => { + const resolvedUrl = tryResolveUrlFromApiRoot(url); + if (!resolvedUrl.startsWith(ApiUtils.getApiRoot())) { + // Different origin URLs might pose a CORS issue during direct downloads. + // Opening in a new tab avoids this limitation, letting the browser handle the download. + Link.openExternalLink(url); + return Promise.resolve(); + } + + return ( fetch(url) .then((response) => response.blob()) .then((blob) => { @@ -35,12 +45,8 @@ export default function fileDownload(url, fileName) { // Clean up and remove the link URL.revokeObjectURL(link.href); link.parentNode.removeChild(link); - return resolve(); }) - .catch(() => { - // file could not be downloaded, open sourceURL in new tab - Link.openExternalLink(url); - return resolve(); - }); - }); + // file could not be downloaded, open sourceURL in new tab + .catch(() => Link.openExternalLink(url)) + ); } diff --git a/src/pages/EditRequestAmountPage.js b/src/pages/EditRequestAmountPage.js index 5fb26e961fad..b6b33774e0fe 100644 --- a/src/pages/EditRequestAmountPage.js +++ b/src/pages/EditRequestAmountPage.js @@ -43,7 +43,7 @@ function EditRequestAmountPage({defaultAmount, defaultCurrency, onNavigateToCurr return ( diff --git a/src/pages/EditRequestPage.js b/src/pages/EditRequestPage.js index 302b7d35a1c9..c958189d68b5 100644 --- a/src/pages/EditRequestPage.js +++ b/src/pages/EditRequestPage.js @@ -184,7 +184,7 @@ function EditRequestPage({betas, report, route, parentReport, policyCategories, }); }} onNavigateToCurrency={() => { - const activeRoute = encodeURIComponent(Navigation.getActiveRoute().replace(/\?.*/, '')); + const activeRoute = encodeURIComponent(Navigation.getActiveRouteWithoutParams()); Navigation.navigate(ROUTES.EDIT_CURRENCY_REQUEST.getRoute(report.reportID, defaultCurrency, activeRoute)); }} /> diff --git a/src/pages/EditSplitBillPage.js b/src/pages/EditSplitBillPage.js index 1342d9297d3e..c4e47e2d4c35 100644 --- a/src/pages/EditSplitBillPage.js +++ b/src/pages/EditSplitBillPage.js @@ -112,7 +112,7 @@ function EditSplitBillPage({route, transaction, draftTransaction}) { }); }} onNavigateToCurrency={() => { - const activeRoute = encodeURIComponent(Navigation.getActiveRoute().replace(/\?.*/, '')); + const activeRoute = encodeURIComponent(Navigation.getActiveRouteWithoutParams()); Navigation.navigate(ROUTES.EDIT_SPLIT_BILL_CURRENCY.getRoute(reportID, reportActionID, defaultCurrency, activeRoute)); }} /> diff --git a/src/pages/EnablePayments/OnfidoPrivacy.js b/src/pages/EnablePayments/OnfidoPrivacy.js index c195e0237034..db5098777744 100644 --- a/src/pages/EnablePayments/OnfidoPrivacy.js +++ b/src/pages/EnablePayments/OnfidoPrivacy.js @@ -86,6 +86,7 @@ function OnfidoPrivacy({walletOnfidoData, translate, form}) { OnfidoPrivacy.propTypes = propTypes; OnfidoPrivacy.defaultProps = defaultProps; +OnfidoPrivacy.displayName = 'OnfidoPrivacy'; export default compose( withLocalize, diff --git a/src/pages/PrivateNotes/PrivateNotesEditPage.js b/src/pages/PrivateNotes/PrivateNotesEditPage.js index 7c8aec8d12de..e3c2c7170ceb 100644 --- a/src/pages/PrivateNotes/PrivateNotesEditPage.js +++ b/src/pages/PrivateNotes/PrivateNotesEditPage.js @@ -7,7 +7,6 @@ import React, {useCallback, useMemo, useRef, useState} from 'react'; import {Keyboard} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; -import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; @@ -19,8 +18,8 @@ import withLocalize from '@components/withLocalize'; import useLocalize from '@hooks/useLocalize'; import compose from '@libs/compose'; import Navigation from '@libs/Navigation/Navigation'; -import * as ReportUtils from '@libs/ReportUtils'; import updateMultilineInputRange from '@libs/UpdateMultilineInputRange'; +import withReportAndPrivateNotesOrNotFound from '@pages/home/report/withReportAndPrivateNotesOrNotFound'; import personalDetailsPropType from '@pages/personalDetailsPropType'; import reportPropTypes from '@pages/reportPropTypes'; import styles from '@styles/styles'; @@ -43,23 +42,14 @@ const propTypes = { accountID: PropTypes.string, }), }).isRequired, - - /** Session of currently logged in user */ - session: PropTypes.shape({ - /** Currently logged in user accountID */ - accountID: PropTypes.number, - }), }; const defaultProps = { report: {}, - session: { - accountID: null, - }, personalDetailsList: {}, }; -function PrivateNotesEditPage({route, personalDetailsList, session, report}) { +function PrivateNotesEditPage({route, personalDetailsList, report}) { const {translate} = useLocalize(); // We need to edit the note in markdown format, but display it in HTML format @@ -81,8 +71,6 @@ function PrivateNotesEditPage({route, personalDetailsList, session, report}) { [report.reportID], ); - const isCurrentUserNote = Number(session.accountID) === Number(route.params.accountID); - // To focus on the input field when the page loads const privateNotesInput = useRef(null); const focusTimeoutRef = useRef(null); @@ -105,8 +93,15 @@ function PrivateNotesEditPage({route, personalDetailsList, session, report}) { const savePrivateNote = () => { const originalNote = lodashGet(report, ['privateNotes', route.params.accountID, 'note'], ''); - const editedNote = Report.handleUserDeletedLinksInHtml(privateNote.trim(), parser.htmlToMarkdown(originalNote).trim()); - Report.updatePrivateNotes(report.reportID, route.params.accountID, editedNote); + + if (privateNote.trim() !== originalNote.trim()) { + const editedNote = Report.handleUserDeletedLinksInHtml(privateNote.trim(), parser.htmlToMarkdown(originalNote).trim()); + Report.updatePrivateNotes(report.reportID, route.params.accountID, editedNote); + } + + // We want to delete saved private note draft after saving the note + debouncedSavePrivateNote(''); + Keyboard.dismiss(); // Take user back to the PrivateNotesView page @@ -119,73 +114,62 @@ function PrivateNotesEditPage({route, personalDetailsList, session, report}) { includeSafeAreaPaddingBottom={false} testID={PrivateNotesEditPage.displayName} > - Navigation.goBack(ROUTES.PRIVATE_NOTES_VIEW.getRoute(report.reportID, route.params.accountID))} + shouldShowBackButton + onCloseButtonPress={() => Navigation.dismissModal()} + /> + - Navigation.goBack(ROUTES.PRIVATE_NOTES_VIEW.getRoute(report.reportID, route.params.accountID))} - shouldShowBackButton - onCloseButtonPress={() => Navigation.dismissModal()} - /> - + {translate( + Str.extractEmailDomain(lodashGet(personalDetailsList, [route.params.accountID, 'login'], '')) === CONST.EMAIL.GUIDES_DOMAIN + ? 'privateNotes.sharedNoteMessage' + : 'privateNotes.personalNoteMessage', + )} + + Report.clearPrivateNotesError(report.reportID, route.params.accountID)} + style={[styles.mb3]} > - - {translate( - Str.extractEmailDomain(lodashGet(personalDetailsList, [route.params.accountID, 'login'], '')) === CONST.EMAIL.GUIDES_DOMAIN - ? 'privateNotes.sharedNoteMessage' - : 'privateNotes.personalNoteMessage', - )} - - { + debouncedSavePrivateNote(text); + setPrivateNote(text); + }} + ref={(el) => { + if (!el) { + return; + } + privateNotesInput.current = el; + updateMultilineInputRange(privateNotesInput.current); }} - onClose={() => Report.clearPrivateNotesError(report.reportID, route.params.accountID)} - style={[styles.mb3]} - > - { - debouncedSavePrivateNote(text); - setPrivateNote(text); - }} - ref={(el) => { - if (!el) { - return; - } - privateNotesInput.current = el; - updateMultilineInputRange(privateNotesInput.current); - }} - /> - - - + /> +
+ ); } @@ -196,13 +180,8 @@ PrivateNotesEditPage.defaultProps = defaultProps; export default compose( withLocalize, + withReportAndPrivateNotesOrNotFound, withOnyx({ - report: { - key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${route.params.reportID.toString()}`, - }, - session: { - key: ONYXKEYS.SESSION, - }, personalDetailsList: { key: ONYXKEYS.PERSONAL_DETAILS_LIST, }, diff --git a/src/pages/PrivateNotes/PrivateNotesListPage.js b/src/pages/PrivateNotes/PrivateNotesListPage.js index ec3905db349e..4d5b348c4b9f 100644 --- a/src/pages/PrivateNotes/PrivateNotesListPage.js +++ b/src/pages/PrivateNotes/PrivateNotesListPage.js @@ -1,14 +1,11 @@ import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; -import React, {useEffect, useMemo} from 'react'; +import React, {useMemo} from 'react'; import {ScrollView} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; -import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; -import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MenuItem from '@components/MenuItem'; -import networkPropTypes from '@components/networkPropTypes'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import {withNetwork} from '@components/OnyxProvider'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -16,12 +13,11 @@ import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; import useLocalize from '@hooks/useLocalize'; import compose from '@libs/compose'; import Navigation from '@libs/Navigation/Navigation'; -import * as ReportUtils from '@libs/ReportUtils'; import * as UserUtils from '@libs/UserUtils'; +import withReportAndPrivateNotesOrNotFound from '@pages/home/report/withReportAndPrivateNotesOrNotFound'; import personalDetailsPropType from '@pages/personalDetailsPropType'; import reportPropTypes from '@pages/reportPropTypes'; import styles from '@styles/styles'; -import * as Report from '@userActions/Report'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -47,8 +43,6 @@ const propTypes = { /** All of the personal details for everyone */ personalDetailsList: PropTypes.objectOf(personalDetailsPropType), - /** Information about the network */ - network: networkPropTypes.isRequired, ...withLocalizePropTypes, }; @@ -60,17 +54,9 @@ const defaultProps = { personalDetailsList: {}, }; -function PrivateNotesListPage({report, personalDetailsList, network, session}) { +function PrivateNotesListPage({report, personalDetailsList, session}) { const {translate} = useLocalize(); - useEffect(() => { - if (network.isOffline && report.isLoadingPrivateNotes) { - return; - } - Report.getReportPrivateNote(report.reportID); - // eslint-disable-next-line react-hooks/exhaustive-deps -- do not add isLoadingPrivateNotes to dependencies - }, [report.reportID, network.isOffline]); - /** * Gets the menu item for each workspace * @@ -124,26 +110,12 @@ function PrivateNotesListPage({report, personalDetailsList, network, session}) { includeSafeAreaPaddingBottom={false} testID={PrivateNotesListPage.displayName} > - - Navigation.dismissModal()} - /> - - {report.isLoadingPrivateNotes && _.isEmpty(lodashGet(report, 'privateNotes', {})) ? ( - - ) : ( - _.map(privateNotes, (item, index) => getMenuItem(item, index)) - )} - - + Navigation.dismissModal()} + /> + {_.map(privateNotes, (item, index) => getMenuItem(item, index))} ); } @@ -154,13 +126,8 @@ PrivateNotesListPage.displayName = 'PrivateNotesListPage'; export default compose( withLocalize, + withReportAndPrivateNotesOrNotFound, withOnyx({ - report: { - key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${route.params.reportID.toString()}`, - }, - session: { - key: ONYXKEYS.SESSION, - }, personalDetailsList: { key: ONYXKEYS.PERSONAL_DETAILS_LIST, }, diff --git a/src/pages/PrivateNotes/PrivateNotesViewPage.js b/src/pages/PrivateNotes/PrivateNotesViewPage.js index bb9d96516437..2b836036448d 100644 --- a/src/pages/PrivateNotes/PrivateNotesViewPage.js +++ b/src/pages/PrivateNotes/PrivateNotesViewPage.js @@ -4,7 +4,6 @@ import React from 'react'; import {ScrollView} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; -import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; @@ -13,7 +12,7 @@ import withLocalize from '@components/withLocalize'; import useLocalize from '@hooks/useLocalize'; import compose from '@libs/compose'; import Navigation from '@libs/Navigation/Navigation'; -import * as ReportUtils from '@libs/ReportUtils'; +import withReportAndPrivateNotesOrNotFound from '@pages/home/report/withReportAndPrivateNotesOrNotFound'; import personalDetailsPropType from '@pages/personalDetailsPropType'; import reportPropTypes from '@pages/reportPropTypes'; import styles from '@styles/styles'; @@ -71,33 +70,28 @@ function PrivateNotesViewPage({route, personalDetailsList, session, report}) { includeSafeAreaPaddingBottom={false} testID={PrivateNotesViewPage.displayName} > - - Navigation.goBack(getFallbackRoute())} - subtitle={isCurrentUserNote ? translate('privateNotes.myNote') : `${lodashGet(personalDetailsList, [route.params.accountID, 'login'], '')} note`} - shouldShowBackButton - onCloseButtonPress={() => Navigation.dismissModal()} - /> - - - isCurrentUserNote && Navigation.navigate(ROUTES.PRIVATE_NOTES_EDIT.getRoute(report.reportID, route.params.accountID))} - shouldShowRightIcon={isCurrentUserNote} - numberOfLinesTitle={0} - shouldRenderAsHTML - brickRoadIndicator={!_.isEmpty(lodashGet(report, ['privateNotes', route.params.accountID, 'errors'], '')) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} - disabled={!isCurrentUserNote} - shouldGreyOutWhenDisabled={false} - /> - - - + Navigation.goBack(getFallbackRoute())} + subtitle={isCurrentUserNote ? translate('privateNotes.myNote') : `${lodashGet(personalDetailsList, [route.params.accountID, 'login'], '')} note`} + shouldShowBackButton + onCloseButtonPress={() => Navigation.dismissModal()} + /> + + + isCurrentUserNote && Navigation.navigate(ROUTES.PRIVATE_NOTES_EDIT.getRoute(report.reportID, route.params.accountID))} + shouldShowRightIcon={isCurrentUserNote} + numberOfLinesTitle={0} + shouldRenderAsHTML + brickRoadIndicator={!_.isEmpty(lodashGet(report, ['privateNotes', route.params.accountID, 'errors'], '')) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} + disabled={!isCurrentUserNote} + shouldGreyOutWhenDisabled={false} + /> + + ); } @@ -108,13 +102,8 @@ PrivateNotesViewPage.defaultProps = defaultProps; export default compose( withLocalize, + withReportAndPrivateNotesOrNotFound, withOnyx({ - report: { - key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${route.params.reportID.toString()}`, - }, - session: { - key: ONYXKEYS.SESSION, - }, personalDetailsList: { key: ONYXKEYS.PERSONAL_DETAILS_LIST, }, diff --git a/src/pages/TeachersUnite/ImTeacherUpdateEmailPage.js b/src/pages/TeachersUnite/ImTeacherUpdateEmailPage.js index 5d7c1d960e3a..38065ac8ab8e 100644 --- a/src/pages/TeachersUnite/ImTeacherUpdateEmailPage.js +++ b/src/pages/TeachersUnite/ImTeacherUpdateEmailPage.js @@ -17,6 +17,7 @@ const defaultProps = {}; function ImTeacherUpdateEmailPage() { const {translate} = useLocalize(); + const activeRoute = Navigation.getActiveRouteWithoutParams(); return ( @@ -31,7 +32,7 @@ function ImTeacherUpdateEmailPage() { title={translate('teachersUnitePage.updateYourEmail')} subtitle={translate('teachersUnitePage.schoolMailAsDefault')} linkKey="teachersUnitePage.contactMethods" - onLinkPress={() => Navigation.navigate(ROUTES.SETTINGS_CONTACT_METHODS)} + onLinkPress={() => Navigation.navigate(ROUTES.SETTINGS_CONTACT_METHODS.getRoute(activeRoute))} iconWidth={variables.signInLogoWidthLargeScreen} iconHeight={variables.lhnLogoWidth} /> @@ -40,7 +41,7 @@ function ImTeacherUpdateEmailPage() { success accessibilityLabel={translate('teachersUnitePage.updateEmail')} text={translate('teachersUnitePage.updateEmail')} - onPress={() => Navigation.navigate(ROUTES.SETTINGS_CONTACT_METHODS)} + onPress={() => Navigation.navigate(ROUTES.SETTINGS_CONTACT_METHODS.getRoute(activeRoute))} /> diff --git a/src/pages/TeachersUnite/KnowATeacherPage.js b/src/pages/TeachersUnite/KnowATeacherPage.js index d8dcc74faac0..c9d429a14596 100644 --- a/src/pages/TeachersUnite/KnowATeacherPage.js +++ b/src/pages/TeachersUnite/KnowATeacherPage.js @@ -5,7 +5,8 @@ import React, {useCallback} from 'react'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; -import Form from '@components/Form'; +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import Text from '@components/Text'; @@ -99,7 +100,7 @@ function KnowATeacherPage(props) { title={translate('teachersUnitePage.iKnowATeacher')} onBackButtonPress={() => Navigation.goBack(ROUTES.TEACHERS_UNITE)} /> -
{translate('teachersUnitePage.getInTouch')} - - - -
+ ); } diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.js similarity index 98% rename from src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js rename to src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.js index 6b375fb5ffa5..6c661992fe20 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.js @@ -25,6 +25,8 @@ import * as ReportUtils from '@libs/ReportUtils'; import * as SuggestionUtils from '@libs/SuggestionUtils'; import updateMultilineInputRange from '@libs/UpdateMultilineInputRange'; import willBlurTextInputOnTapOutsideFunc from '@libs/willBlurTextInputOnTapOutside'; +import SilentCommentUpdater from '@pages/home/report/ReportActionCompose/SilentCommentUpdater'; +import Suggestions from '@pages/home/report/ReportActionCompose/Suggestions'; import containerComposeStyles from '@styles/containerComposeStyles'; import styles from '@styles/styles'; import themeColors from '@styles/themes/default'; @@ -35,8 +37,6 @@ import * as User from '@userActions/User'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {defaultProps, propTypes} from './composerWithSuggestionsProps'; -import SilentCommentUpdater from './SilentCommentUpdater'; -import Suggestions from './Suggestions'; const {RNTextInputReset} = NativeModules; @@ -104,6 +104,8 @@ function ComposerWithSuggestions({ forwardedRef, isNextModalWillOpenRef, editFocused, + // For testing + children, }) { const {preferredLocale} = useLocalize(); const isFocused = useIsFocused(); @@ -645,6 +647,9 @@ function ComposerWithSuggestions({ updateComment={updateComment} commentRef={commentRef} /> + + {/* Only used for testing so far */} + {children} ); } diff --git a/src/pages/home/report/ReportActionCompose/composerWithSuggestionsProps.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/composerWithSuggestionsProps.js similarity index 100% rename from src/pages/home/report/ReportActionCompose/composerWithSuggestionsProps.js rename to src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/composerWithSuggestionsProps.js diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.e2e.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.e2e.js new file mode 100644 index 000000000000..cbbd1758c9cb --- /dev/null +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.e2e.js @@ -0,0 +1,52 @@ +import _ from 'lodash'; +import React, {useEffect} from 'react'; +import E2EClient from '@libs/E2E/client'; +import ComposerWithSuggestions from './ComposerWithSuggestions'; + +let rerenderCount = 0; +const getRerenderCount = () => rerenderCount; +const resetRerenderCount = () => { + rerenderCount = 0; +}; + +function IncrementRenderCount() { + rerenderCount += 1; + return null; +} + +const ComposerWithSuggestionsE2e = React.forwardRef((props, ref) => { + // Eventually Auto focus on e2e tests + useEffect(() => { + if (_.get(E2EClient.getCurrentActiveTestConfig(), 'reportScreen.autoFocus', false) === false) { + return; + } + + // We need to wait for the component to be mounted before focusing + setTimeout(() => { + if (!ref || !ref.current) { + return; + } + + ref.current.focus(true); + }, 1); + }, [ref]); + + return ( + + {/* Important: + this has to be a child, as this container might not + re-render while the actual ComposerWithSuggestions will. + */} + + + ); +}); + +ComposerWithSuggestionsE2e.displayName = 'ComposerWithSuggestionsE2e'; + +export default ComposerWithSuggestionsE2e; +export {getRerenderCount, resetRerenderCount}; diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.js new file mode 100644 index 000000000000..f2aebd390ba6 --- /dev/null +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.js @@ -0,0 +1,3 @@ +import ComposerWithSuggestions from './ComposerWithSuggestions'; + +export default ComposerWithSuggestions; diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js index f090a942e097..c0a1151f0202 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js @@ -464,6 +464,7 @@ function ReportActionCompose({ ReportActionCompose.propTypes = propTypes; ReportActionCompose.defaultProps = defaultProps; +ReportActionCompose.displayName = 'ReportActionCompose'; export default compose( withNetwork(), diff --git a/src/pages/home/report/ReportActionItem.js b/src/pages/home/report/ReportActionItem.js index 4da88fd5d352..c91a29a37eec 100644 --- a/src/pages/home/report/ReportActionItem.js +++ b/src/pages/home/report/ReportActionItem.js @@ -58,6 +58,7 @@ import * as User from '@userActions/User'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import AnimatedEmptyStateBackground from './AnimatedEmptyStateBackground'; import * as ContextMenuActions from './ContextMenu/ContextMenuActions'; import MiniReportActionContextMenu from './ContextMenu/MiniReportActionContextMenu'; import * as ReportActionContextMenu from './ContextMenu/ReportActionContextMenu'; @@ -576,23 +577,31 @@ function ReportActionItem(props) { if (ReportUtils.isCanceledTaskReport(props.report, parentReportAction)) { content = ( <> - - ${props.translate('parentReportAction.deletedTask')}`} /> - - + + + + ${props.translate('parentReportAction.deletedTask')}`} /> + + + ); } else { content = ( - + <> + + + + + ); } } @@ -765,6 +774,7 @@ export default compose( prevProps.shouldDisplayNewMarker === nextProps.shouldDisplayNewMarker && _.isEqual(prevProps.emojiReactions, nextProps.emojiReactions) && _.isEqual(prevProps.action, nextProps.action) && + _.isEqual(prevProps.iouReport, nextProps.iouReport) && _.isEqual(prevProps.report.pendingFields, nextProps.report.pendingFields) && _.isEqual(prevProps.report.isDeletedParentAction, nextProps.report.isDeletedParentAction) && _.isEqual(prevProps.report.errorFields, nextProps.report.errorFields) && diff --git a/src/pages/home/report/ReportActionItemFragment.js b/src/pages/home/report/ReportActionItemFragment.js index 24e1c6bc1ef6..bbaa13484614 100644 --- a/src/pages/home/report/ReportActionItemFragment.js +++ b/src/pages/home/report/ReportActionItemFragment.js @@ -63,6 +63,12 @@ const propTypes = { /** Whether the comment is a thread parent message/the first message in a thread */ isThreadParentMessage: PropTypes.bool, + /** Whether the report action type is 'APPROVED' or 'SUBMITTED'. Used to style system messages from Old Dot */ + isApprovedOrSubmittedReportAction: PropTypes.bool, + + /** Used to format RTL display names in Old Dot system messages e.g. Arabic */ + isFragmentContainingDisplayName: PropTypes.bool, + ...windowDimensionsPropTypes, /** localization props */ @@ -86,6 +92,8 @@ const defaultProps = { delegateAccountID: 0, actorIcon: {}, isThreadParentMessage: false, + isApprovedOrSubmittedReportAction: false, + isFragmentContainingDisplayName: false, displayAsGroup: false, }; @@ -152,8 +160,15 @@ function ReportActionItemFragment(props) { ); } - case 'TEXT': - return ( + case 'TEXT': { + return props.isApprovedOrSubmittedReportAction ? ( + + {props.isFragmentContainingDisplayName ? convertToLTR(props.fragment.text) : props.fragment.text} + + ) : ( ); + } case 'LINK': return LINK; case 'INTEGRATION_COMMENT': diff --git a/src/pages/home/report/ReportActionItemMessage.js b/src/pages/home/report/ReportActionItemMessage.js index 37aaa5adf287..4c6603c052a3 100644 --- a/src/pages/home/report/ReportActionItemMessage.js +++ b/src/pages/home/report/ReportActionItemMessage.js @@ -7,6 +7,7 @@ import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; import styles from '@styles/styles'; +import CONST from '@src/CONST'; import ReportActionItemFragment from './ReportActionItemFragment'; import reportActionPropTypes from './reportActionPropTypes'; @@ -47,23 +48,45 @@ function ReportActionItemMessage(props) { } } + const isApprovedOrSubmittedReportAction = _.contains([CONST.REPORT.ACTIONS.TYPE.APPROVED, CONST.REPORT.ACTIONS.TYPE.SUBMITTED], props.action.actionName); + + /** + * Get the ReportActionItemFragments + * @param {Boolean} shouldWrapInText determines whether the fragments are wrapped in a Text component + * @returns {Object} report action item fragments + */ + const renderReportActionItemFragments = (shouldWrapInText) => { + const reportActionItemFragments = _.map(messages, (fragment, index) => ( + + )); + + // Approving or submitting reports in oldDot results in system messages made up of multiple fragments of `TEXT` type + // which we need to wrap in `` to prevent them rendering on separate lines. + + return shouldWrapInText ? {reportActionItemFragments} : reportActionItemFragments; + }; + return ( {!props.isHidden ? ( - _.map(messages, (fragment, index) => ( - - )) + renderReportActionItemFragments(isApprovedOrSubmittedReportAction) ) : ( {props.translate('moderation.flaggedContent')} )} diff --git a/src/pages/home/report/ReportActionItemMessageEdit.js b/src/pages/home/report/ReportActionItemMessageEdit.js index 67c3d5f5c5ec..2acb624e28ef 100644 --- a/src/pages/home/report/ReportActionItemMessageEdit.js +++ b/src/pages/home/report/ReportActionItemMessageEdit.js @@ -169,6 +169,15 @@ function ReportActionItemMessageEdit(props) { [props.action.reportActionID], ); + // Scroll content of textInputRef to bottom + useEffect(() => { + if (!textInputRef.current) { + return; + } + textInputRef.current.focus(); + textInputRef.current.scrollTop = textInputRef.current.scrollHeight; + }, []); + useEffect(() => { // For mobile Safari, updating the selection prop on an unfocused input will cause it to automatically gain focus // and subsequent programmatic focus shifts (e.g., modal focus trap) to show the blue frame (:focus-visible style), diff --git a/src/pages/home/report/ReportActionsList.js b/src/pages/home/report/ReportActionsList.js index 4fd2bd21c99e..11c8077745f9 100644 --- a/src/pages/home/report/ReportActionsList.js +++ b/src/pages/home/report/ReportActionsList.js @@ -429,6 +429,7 @@ function ReportActionsList({ keyboardShouldPersistTaps="handled" onLayout={onLayoutInner} onScroll={trackVerticalScrolling} + onScrollToIndexFailed={() => {}} extraData={extraData} /> diff --git a/src/pages/home/report/ReportActionsView.js b/src/pages/home/report/ReportActionsView.js index 2608aaf51c9b..28ddcd94dfb2 100755 --- a/src/pages/home/report/ReportActionsView.js +++ b/src/pages/home/report/ReportActionsView.js @@ -2,6 +2,7 @@ import {useIsFocused} from '@react-navigation/native'; import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; import React, {useContext, useEffect, useMemo, useRef} from 'react'; +import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; import networkPropTypes from '@components/networkPropTypes'; import {withNetwork} from '@components/OnyxProvider'; @@ -9,15 +10,19 @@ import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; import withWindowDimensions, {windowDimensionsPropTypes} from '@components/withWindowDimensions'; import useCopySelectionHelper from '@hooks/useCopySelectionHelper'; import useInitialValue from '@hooks/useInitialValue'; +import usePrevious from '@hooks/usePrevious'; import compose from '@libs/compose'; import getIsReportFullyVisible from '@libs/getIsReportFullyVisible'; import Performance from '@libs/Performance'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; +import {isUserCreatedPolicyRoom} from '@libs/ReportUtils'; +import {didUserLogInDuringSession} from '@libs/SessionUtils'; import {ReactionListContext} from '@pages/home/ReportScreenContext'; import reportPropTypes from '@pages/reportPropTypes'; import * as Report from '@userActions/Report'; import Timing from '@userActions/Timing'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import PopoverReactionList from './ReactionList/PopoverReactionList'; import reportActionPropTypes from './reportActionPropTypes'; import ReportActionsList from './ReportActionsList'; @@ -54,6 +59,12 @@ const propTypes = { avatar: PropTypes.string, }), + /** Session info for the currently logged in user. */ + session: PropTypes.shape({ + /** Currently logged in user authToken */ + authToken: PropTypes.string, + }), + ...windowDimensionsPropTypes, ...withLocalizePropTypes, }; @@ -64,6 +75,9 @@ const defaultProps = { isLoadingInitialReportActions: false, isLoadingOlderReportActions: false, isLoadingNewerReportActions: false, + session: { + authTokenType: '', + }, }; function ReportActionsView(props) { @@ -76,6 +90,8 @@ function ReportActionsView(props) { const mostRecentIOUReportActionID = useInitialValue(() => ReportActionsUtils.getMostRecentIOURequestActionID(props.reportActions)); const prevNetworkRef = useRef(props.network); + const prevAuthTokenType = usePrevious(props.session.authTokenType); + const prevIsSmallScreenWidthRef = useRef(props.isSmallScreenWidth); const isFocused = useIsFocused(); @@ -118,6 +134,18 @@ function ReportActionsView(props) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [props.network, props.report, isReportFullyVisible]); + useEffect(() => { + const wasLoginChangedDetected = prevAuthTokenType === 'anonymousAccount' && !props.session.authTokenType; + if (wasLoginChangedDetected && didUserLogInDuringSession() && isUserCreatedPolicyRoom(props.report)) { + if (isReportFullyVisible) { + openReportIfNecessary(); + } else { + Report.reconnect(reportID); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [props.session, props.report, isReportFullyVisible]); + useEffect(() => { const prevIsSmallScreenWidth = prevIsSmallScreenWidthRef.current; // If the view is expanded from mobile to desktop layout @@ -261,6 +289,10 @@ function arePropsEqual(oldProps, newProps) { return false; } + if (lodashGet(oldProps.session, 'authTokenType') !== lodashGet(newProps.session, 'authTokenType')) { + return false; + } + if (oldProps.isLoadingInitialReportActions !== newProps.isLoadingInitialReportActions) { return false; } @@ -334,4 +366,14 @@ function arePropsEqual(oldProps, newProps) { const MemoizedReportActionsView = React.memo(ReportActionsView, arePropsEqual); -export default compose(Performance.withRenderTrace({id: ' rendering'}), withWindowDimensions, withLocalize, withNetwork())(MemoizedReportActionsView); +export default compose( + Performance.withRenderTrace({id: ' rendering'}), + withWindowDimensions, + withLocalize, + withNetwork(), + withOnyx({ + session: { + key: ONYXKEYS.SESSION, + }, + }), +)(MemoizedReportActionsView); diff --git a/src/pages/home/report/withReportAndPrivateNotesOrNotFound.js b/src/pages/home/report/withReportAndPrivateNotesOrNotFound.js new file mode 100644 index 000000000000..3982dd5ab542 --- /dev/null +++ b/src/pages/home/report/withReportAndPrivateNotesOrNotFound.js @@ -0,0 +1,130 @@ +import lodashGet from 'lodash/get'; +import PropTypes from 'prop-types'; +import React, {useEffect, useMemo} from 'react'; +import {withOnyx} from 'react-native-onyx'; +import _ from 'underscore'; +import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; +import networkPropTypes from '@components/networkPropTypes'; +import {withNetwork} from '@components/OnyxProvider'; +import * as Report from '@libs/actions/Report'; +import compose from '@libs/compose'; +import getComponentDisplayName from '@libs/getComponentDisplayName'; +import * as ReportUtils from '@libs/ReportUtils'; +import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; +import reportPropTypes from '@pages/reportPropTypes'; +import ONYXKEYS from '@src/ONYXKEYS'; +import withReportOrNotFound from './withReportOrNotFound'; + +const propTypes = { + /** The HOC takes an optional ref as a prop and passes it as a ref to the wrapped component. + * That way, if a ref is passed to a component wrapped in the HOC, the ref is a reference to the wrapped component, not the HOC. */ + forwardedRef: PropTypes.func, + + /** The report currently being looked at */ + report: reportPropTypes, + + /** Information about the network */ + network: networkPropTypes.isRequired, + + /** Session of currently logged in user */ + session: PropTypes.shape({ + /** accountID of currently logged in user */ + accountID: PropTypes.number, + }), + + route: PropTypes.shape({ + /** Params from the URL path */ + params: PropTypes.shape({ + /** reportID and accountID passed via route: /r/:reportID/notes/:accountID */ + reportID: PropTypes.string, + accountID: PropTypes.string, + }), + }).isRequired, +}; + +const defaultProps = { + forwardedRef: () => {}, + report: {}, + session: { + accountID: null, + }, +}; + +export default function (WrappedComponent) { + // eslint-disable-next-line rulesdir/no-negated-variables + function WithReportAndPrivateNotesOrNotFound({forwardedRef, ...props}) { + const {route, report, network, session} = props; + const accountID = route.params.accountID; + const isPrivateNotesFetchTriggered = !_.isUndefined(report.isLoadingPrivateNotes); + + useEffect(() => { + // Do not fetch private notes if isLoadingPrivateNotes is already defined, or if network is offline. + if (isPrivateNotesFetchTriggered || network.isOffline) { + return; + } + + Report.getReportPrivateNote(report.reportID); + // eslint-disable-next-line react-hooks/exhaustive-deps -- do not add report.isLoadingPrivateNotes to dependencies + }, [report.reportID, network.isOffline, isPrivateNotesFetchTriggered]); + + const isPrivateNotesEmpty = accountID ? _.isEmpty(lodashGet(report, ['privateNotes', accountID, 'note'], '')) : _.isEmpty(report.privateNotes); + const shouldShowFullScreenLoadingIndicator = !isPrivateNotesFetchTriggered || (isPrivateNotesEmpty && report.isLoadingPrivateNotes); + + // eslint-disable-next-line rulesdir/no-negated-variables + const shouldShowNotFoundPage = useMemo(() => { + // Show not found view if the report is archived, or if the note is not of current user. + if (ReportUtils.isArchivedRoom(report) || (accountID && Number(session.accountID) !== Number(accountID))) { + return true; + } + + // Don't show not found view if the notes are still loading, or if the notes are non-empty. + if (shouldShowFullScreenLoadingIndicator || !isPrivateNotesEmpty) { + return false; + } + + // As notes being empty and not loading is a valid case, show not found view only in offline mode. + return network.isOffline; + }, [report, network.isOffline, accountID, session.accountID, isPrivateNotesEmpty, shouldShowFullScreenLoadingIndicator]); + + if (shouldShowFullScreenLoadingIndicator) { + return ; + } + + if (shouldShowNotFoundPage) { + return ; + } + + return ( + + ); + } + + WithReportAndPrivateNotesOrNotFound.propTypes = propTypes; + WithReportAndPrivateNotesOrNotFound.defaultProps = defaultProps; + WithReportAndPrivateNotesOrNotFound.displayName = `withReportAndPrivateNotesOrNotFound(${getComponentDisplayName(WrappedComponent)})`; + + // eslint-disable-next-line rulesdir/no-negated-variables + const WithReportAndPrivateNotesOrNotFoundWithRef = React.forwardRef((props, ref) => ( + + )); + + WithReportAndPrivateNotesOrNotFoundWithRef.displayName = 'WithReportAndPrivateNotesOrNotFoundWithRef'; + + return compose( + withReportOrNotFound(), + withOnyx({ + session: { + key: ONYXKEYS.SESSION, + }, + }), + withNetwork(), + )(WithReportAndPrivateNotesOrNotFoundWithRef); +} diff --git a/src/pages/home/report/withReportOrNotFound.tsx b/src/pages/home/report/withReportOrNotFound.tsx index 81d1376abd37..95997da71a2d 100644 --- a/src/pages/home/report/withReportOrNotFound.tsx +++ b/src/pages/home/report/withReportOrNotFound.tsx @@ -36,7 +36,7 @@ export default function ( const isReportIdInRoute = props.route.params.reportID?.length; if (shouldRequireReportID || isReportIdInRoute) { - const shouldShowFullScreenLoadingIndicator = props.isLoadingReportData && (!Object.entries(props.report ?? {}).length || !props.report?.reportID); + const shouldShowFullScreenLoadingIndicator = props.isLoadingReportData !== false && (!Object.entries(props.report ?? {}).length || !props.report?.reportID); const shouldShowNotFoundPage = !Object.entries(props.report ?? {}).length || !props.report?.reportID || !ReportUtils.canAccessReport(props.report, props.policies, props.betas, {}); diff --git a/src/pages/iou/MoneyRequestMerchantPage.js b/src/pages/iou/MoneyRequestMerchantPage.js index 5c01484310ff..f072c0f78535 100644 --- a/src/pages/iou/MoneyRequestMerchantPage.js +++ b/src/pages/iou/MoneyRequestMerchantPage.js @@ -1,6 +1,6 @@ import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; -import React, {useCallback, useEffect, useRef} from 'react'; +import React, {useCallback, useEffect} from 'react'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; @@ -8,6 +8,7 @@ import Form from '@components/Form'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import TextInput from '@components/TextInput'; +import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useLocalize from '@hooks/useLocalize'; import Navigation from '@libs/Navigation/Navigation'; import styles from '@styles/styles'; @@ -47,7 +48,7 @@ const defaultProps = { function MoneyRequestMerchantPage({iou, route}) { const {translate} = useLocalize(); - const inputRef = useRef(null); + const {inputCallbackRef} = useAutoFocusInput(); const iouType = lodashGet(route, 'params.iouType', ''); const reportID = lodashGet(route, 'params.reportID', ''); @@ -92,7 +93,6 @@ function MoneyRequestMerchantPage({iou, route}) { inputRef.current && inputRef.current.focus()} testID={MoneyRequestMerchantPage.displayName} > (inputRef.current = el)} + ref={inputCallbackRef} /> diff --git a/src/pages/iou/ReceiptSelector/index.js b/src/pages/iou/ReceiptSelector/index.js index 672b1458bf8e..b6a15d69f02c 100644 --- a/src/pages/iou/ReceiptSelector/index.js +++ b/src/pages/iou/ReceiptSelector/index.js @@ -3,7 +3,6 @@ import PropTypes from 'prop-types'; import React, {useCallback, useContext, useReducer, useRef, useState} from 'react'; import {ActivityIndicator, PanResponder, PixelRatio, Text, View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; -import _ from 'underscore'; import Hand from '@assets/images/hand.svg'; import ReceiptUpload from '@assets/images/receipt-upload.svg'; import Shutter from '@assets/images/shutter.svg'; @@ -103,7 +102,7 @@ function ReceiptSelector({route, transactionID, iou, report}) { function validateReceipt(file) { const {fileExtension} = FileUtils.splitExtensionFromFileName(lodashGet(file, 'name', '')); - if (_.contains(CONST.API_ATTACHMENT_VALIDATIONS.UNALLOWED_EXTENSIONS, fileExtension.toLowerCase())) { + if (!CONST.API_ATTACHMENT_VALIDATIONS.ALLOWED_RECEIPT_EXTENSIONS.includes(fileExtension.toLowerCase())) { setUploadReceiptError(true, 'attachmentPicker.wrongFileType', 'attachmentPicker.notAllowedExtension'); return false; } diff --git a/src/pages/iou/ReceiptSelector/index.native.js b/src/pages/iou/ReceiptSelector/index.native.js index d47a2c7739a2..afcd546d4abf 100644 --- a/src/pages/iou/ReceiptSelector/index.native.js +++ b/src/pages/iou/ReceiptSelector/index.native.js @@ -103,6 +103,25 @@ function ReceiptSelector({route, report, iou, transactionID, isInTabNavigator, s }; }, []); + const validateReceipt = (file) => { + const {fileExtension} = FileUtils.splitExtensionFromFileName(lodashGet(file, 'name', '')); + if (!CONST.API_ATTACHMENT_VALIDATIONS.ALLOWED_RECEIPT_EXTENSIONS.includes(fileExtension.toLowerCase())) { + Alert.alert(translate('attachmentPicker.wrongFileType'), translate('attachmentPicker.notAllowedExtension')); + return false; + } + + if (lodashGet(file, 'size', 0) > CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE) { + Alert.alert(translate('attachmentPicker.attachmentTooLarge'), translate('attachmentPicker.sizeExceeded')); + return false; + } + + if (lodashGet(file, 'size', 0) < CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE) { + Alert.alert(translate('attachmentPicker.attachmentTooSmall'), translate('attachmentPicker.sizeNotMet')); + return false; + } + return true; + }; + const askForPermissions = () => { // There's no way we can check for the BLOCKED status without requesting the permission first // https://github.com/zoontek/react-native-permissions/blob/a836e114ce3a180b2b23916292c79841a267d828/README.md?plain=1#L670 @@ -212,6 +231,9 @@ function ReceiptSelector({route, report, iou, transactionID, isInTabNavigator, s onPress={() => { openPicker({ onPicked: (file) => { + if (!validateReceipt(file)) { + return; + } const filePath = file.uri; IOU.setMoneyRequestReceipt(filePath, file.name); diff --git a/src/pages/iou/steps/NewRequestAmountPage.js b/src/pages/iou/steps/NewRequestAmountPage.js index e531e6706f55..a045fc6399e9 100644 --- a/src/pages/iou/steps/NewRequestAmountPage.js +++ b/src/pages/iou/steps/NewRequestAmountPage.js @@ -123,7 +123,7 @@ function NewRequestAmountPage({route, iou, report, selectedTab}) { } // Remove query from the route and encode it. - const activeRoute = encodeURIComponent(Navigation.getActiveRoute().replace(/\?.*/, '')); + const activeRoute = encodeURIComponent(Navigation.getActiveRouteWithoutParams()); Navigation.navigate(ROUTES.MONEY_REQUEST_CURRENCY.getRoute(iouType, reportID, currency, activeRoute)); }; diff --git a/src/pages/settings/InitialSettingsPage.js b/src/pages/settings/InitialSettingsPage.js index 2eb0374e7ed7..8fb752d2a574 100755 --- a/src/pages/settings/InitialSettingsPage.js +++ b/src/pages/settings/InitialSettingsPage.js @@ -411,6 +411,7 @@ function InitialSettingsPage(props) { InitialSettingsPage.propTypes = propTypes; InitialSettingsPage.defaultProps = defaultProps; +InitialSettingsPage.displayName = 'InitialSettingsPage'; export default compose( withLocalize, diff --git a/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.js b/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.js index c48191957999..b97bc2521e55 100644 --- a/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.js +++ b/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.js @@ -123,7 +123,7 @@ class ContactMethodDetailsPage extends Component { // Navigate to methods page on successful magic code verification // validatedDate property is responsible to decide the status of the magic code verification if (!prevValidatedDate && validatedDate) { - Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS); + Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.route); } } @@ -236,8 +236,8 @@ class ContactMethodDetailsPage extends Component { Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS)} - onLinkPress={() => Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS)} + onBackButtonPress={() => Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.route)} + onLinkPress={() => Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.route)} /> ); @@ -255,7 +255,7 @@ class ContactMethodDetailsPage extends Component { > Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS)} + onBackButtonPress={() => Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.route)} /> Navigation.goBack(ROUTES.SETTINGS_PROFILE)} + onBackButtonPress={() => Navigation.goBack(navigateBackTo)} /> diff --git a/src/pages/settings/Profile/Contacts/NewContactMethodPage.js b/src/pages/settings/Profile/Contacts/NewContactMethodPage.js index 9e40ef65dfd6..ed8400e7e775 100644 --- a/src/pages/settings/Profile/Contacts/NewContactMethodPage.js +++ b/src/pages/settings/Profile/Contacts/NewContactMethodPage.js @@ -103,7 +103,7 @@ function NewContactMethodPage(props) { > Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS)} + onBackButtonPress={() => Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.route)} /> { if (!address) { @@ -89,6 +90,7 @@ function AddressPage({privatePersonalDetails, route}) { setState(address.state); setCurrentCountry(address.country); setCity(address.city); + setZipcode(address.zip); }, [address]); /** @@ -137,20 +139,28 @@ function AddressPage({privatePersonalDetails, route}) { }, []); const handleAddressChange = useCallback((value, key) => { - if (key !== 'country' && key !== 'state' && key !== 'city') { + if (key !== 'country' && key !== 'state' && key !== 'city' && key !== 'zipPostCode') { return; } if (key === 'country') { setCurrentCountry(value); setState(''); setCity(''); + setZipcode(''); return; } if (key === 'state') { setState(value); + setCity(''); + setZipcode(''); + return; + } + if (key === 'city') { + setCity(value); + setZipcode(''); return; } - setCity(value); + setZipcode(value); }, []); useEffect(() => { @@ -254,9 +264,10 @@ function AddressPage({privatePersonalDetails, route}) { accessibilityLabel={translate('common.zipPostCode')} accessibilityRole={CONST.ACCESSIBILITY_ROLE.TEXT} autoCapitalize="characters" - defaultValue={address.zip || ''} + value={zipcode || ''} maxLength={CONST.BANK_ACCOUNT.MAX_LENGTH.ZIP_CODE} hint={zipFormat} + onValueChange={handleAddressChange} /> )} diff --git a/src/pages/settings/Profile/ProfilePage.js b/src/pages/settings/Profile/ProfilePage.js index 7ec8e05b76ff..759987bf7c1e 100755 --- a/src/pages/settings/Profile/ProfilePage.js +++ b/src/pages/settings/Profile/ProfilePage.js @@ -79,7 +79,7 @@ function ProfilePage(props) { { description: props.translate('contacts.contactMethod'), title: props.formatPhoneNumber(lodashGet(currentUserDetails, 'login', '')), - pageRoute: ROUTES.SETTINGS_CONTACT_METHODS, + pageRoute: ROUTES.SETTINGS_CONTACT_METHODS.route, brickRoadIndicator: contactMethodBrickRoadIndicator, }, ...(Permissions.canUseCustomStatus(props.betas) diff --git a/src/pages/settings/Security/TwoFactorAuth/Steps/CodesStep.js b/src/pages/settings/Security/TwoFactorAuth/Steps/CodesStep.js index 6d74a0f91bd9..8ae3f4d25f33 100644 --- a/src/pages/settings/Security/TwoFactorAuth/Steps/CodesStep.js +++ b/src/pages/settings/Security/TwoFactorAuth/Steps/CodesStep.js @@ -134,6 +134,7 @@ function CodesStep({account = defaultAccount}) { } CodesStep.propTypes = TwoFactorAuthPropTypes; +CodesStep.displayName = 'CodesStep'; // eslint-disable-next-line rulesdir/onyx-props-must-have-default export default withOnyx({ diff --git a/src/pages/settings/Security/TwoFactorAuth/Steps/DisabledStep.js b/src/pages/settings/Security/TwoFactorAuth/Steps/DisabledStep.js index 317e510024b0..6f0160503bb3 100644 --- a/src/pages/settings/Security/TwoFactorAuth/Steps/DisabledStep.js +++ b/src/pages/settings/Security/TwoFactorAuth/Steps/DisabledStep.js @@ -32,4 +32,6 @@ function DisabledStep() { ); } +DisabledStep.displayName = 'DisabledStep'; + export default DisabledStep; diff --git a/src/pages/settings/Security/TwoFactorAuth/Steps/EnabledStep.js b/src/pages/settings/Security/TwoFactorAuth/Steps/EnabledStep.js index 6b0edfc49da3..497846bc20d4 100644 --- a/src/pages/settings/Security/TwoFactorAuth/Steps/EnabledStep.js +++ b/src/pages/settings/Security/TwoFactorAuth/Steps/EnabledStep.js @@ -63,4 +63,6 @@ function EnabledStep() { ); } +EnabledStep.displayName = 'EnabledStep'; + export default EnabledStep; diff --git a/src/pages/settings/Security/TwoFactorAuth/Steps/VerifyStep.js b/src/pages/settings/Security/TwoFactorAuth/Steps/VerifyStep.js index 440b40c5363c..c1033e3e3d4f 100644 --- a/src/pages/settings/Security/TwoFactorAuth/Steps/VerifyStep.js +++ b/src/pages/settings/Security/TwoFactorAuth/Steps/VerifyStep.js @@ -150,6 +150,7 @@ VerifyStep.propTypes = { }), }; VerifyStep.defaultProps = defaultProps; +VerifyStep.displayName = 'VerifyStep'; // eslint-disable-next-line rulesdir/onyx-props-must-have-default export default withOnyx({ diff --git a/src/pages/signin/SAMLSignInPage/index.js b/src/pages/signin/SAMLSignInPage/index.js index 67154c8e85fe..fc82cc26d497 100644 --- a/src/pages/signin/SAMLSignInPage/index.js +++ b/src/pages/signin/SAMLSignInPage/index.js @@ -60,6 +60,7 @@ function SAMLSignInPage({credentials}) { SAMLSignInPage.propTypes = propTypes; SAMLSignInPage.defaultProps = defaultProps; +SAMLSignInPage.displayName = 'SAMLSignInPage'; export default withOnyx({ credentials: {key: ONYXKEYS.CREDENTIALS}, diff --git a/src/pages/workspace/WorkspaceInitialPage.js b/src/pages/workspace/WorkspaceInitialPage.js index 207db2620f9f..e66a696c775f 100644 --- a/src/pages/workspace/WorkspaceInitialPage.js +++ b/src/pages/workspace/WorkspaceInitialPage.js @@ -173,7 +173,7 @@ function WorkspaceInitialPage(props) { icon: Expensicons.Bank, action: () => policy.outputCurrency === CONST.CURRENCY.USD - ? singleExecution(waitForNavigate(() => ReimbursementAccount.navigateToBankAccountRoute(policy.id, Navigation.getActiveRoute().replace(/\?.*/, ''))))() + ? singleExecution(waitForNavigate(() => ReimbursementAccount.navigateToBankAccountRoute(policy.id, Navigation.getActiveRouteWithoutParams())))() : setIsCurrencyModalOpen(true), brickRoadIndicator: !_.isEmpty(props.reimbursementAccount.errors) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : '', }, diff --git a/src/pages/workspace/WorkspaceInviteMessagePage.js b/src/pages/workspace/WorkspaceInviteMessagePage.js index 1414354b4b38..bab9e526ace5 100644 --- a/src/pages/workspace/WorkspaceInviteMessagePage.js +++ b/src/pages/workspace/WorkspaceInviteMessagePage.js @@ -76,7 +76,7 @@ class WorkspaceInviteMessagePage extends React.Component { this.validate = this.validate.bind(this); this.openPrivacyURL = this.openPrivacyURL.bind(this); this.state = { - welcomeNote: this.getDefaultWelcomeNote(), + welcomeNote: this.props.savedWelcomeMessage || this.getDefaultWelcomeNote(), }; } @@ -228,6 +228,7 @@ class WorkspaceInviteMessagePage extends React.Component { defaultValue={this.state.welcomeNote} value={this.state.welcomeNote} onChangeText={(text) => this.setState({welcomeNote: text})} + shouldSaveDraft /> @@ -251,6 +252,10 @@ export default compose( invitedEmailsToAccountIDsDraft: { key: ({route}) => `${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT}${route.params.policyID.toString()}`, }, + savedWelcomeMessage: { + key: `${ONYXKEYS.FORMS.WORKSPACE_INVITE_MESSAGE_FORM}Draft`, + selector: (draft) => (draft ? draft.welcomeMessage : ''), + }, }), withNavigationFocus, )(WorkspaceInviteMessagePage); diff --git a/src/pages/workspace/WorkspaceSettingsPage.js b/src/pages/workspace/WorkspaceSettingsPage.js index d913ae26c170..2ec17294b17a 100644 --- a/src/pages/workspace/WorkspaceSettingsPage.js +++ b/src/pages/workspace/WorkspaceSettingsPage.js @@ -6,7 +6,8 @@ import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; import Avatar from '@components/Avatar'; import AvatarWithImagePicker from '@components/AvatarWithImagePicker'; -import Form from '@components/Form'; +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; import * as Expensicons from '@components/Icon/Expensicons'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; @@ -98,7 +99,7 @@ function WorkspaceSettingsPage({policy, currencyList, windowWidth, route}) { guidesCallTaskID={CONST.GUIDES_CALL_TASK_IDS.WORKSPACE_SETTINGS} > {(hasVBA) => ( -
- @@ -162,7 +164,7 @@ function WorkspaceSettingsPage({policy, currencyList, windowWidth, route}) { -
+ )} ); diff --git a/src/pages/workspace/WorkspacesListPage.js b/src/pages/workspace/WorkspacesListPage.js index 529963ca26c6..32c968eba62f 100755 --- a/src/pages/workspace/WorkspacesListPage.js +++ b/src/pages/workspace/WorkspacesListPage.js @@ -211,6 +211,7 @@ function WorkspacesListPage({policies, allPolicyMembers, reimbursementAccount, u WorkspacesListPage.propTypes = propTypes; WorkspacesListPage.defaultProps = defaultProps; +WorkspacesListPage.displayName = 'WorkspacesListPage'; export default compose( withPolicyAndFullscreenLoading, diff --git a/src/stories/HeaderWithBackButton.stories.js b/src/stories/HeaderWithBackButton.stories.js index 38d816b8fdd2..eb31413de1d5 100644 --- a/src/stories/HeaderWithBackButton.stories.js +++ b/src/stories/HeaderWithBackButton.stories.js @@ -1,5 +1,8 @@ import React from 'react'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import withNavigationFallback from '@components/withNavigationFallback'; + +const HeaderWithBackButtonWithNavigation = withNavigationFallback(HeaderWithBackButton); /** * We use the Component Story Format for writing stories. Follow the docs here: @@ -8,12 +11,12 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; */ const story = { title: 'Components/HeaderWithBackButton', - component: HeaderWithBackButton, + component: HeaderWithBackButtonWithNavigation, }; function Template(args) { // eslint-disable-next-line react/jsx-props-no-spreading - return ; + return ; } // Arguments can be passed to the component by binding diff --git a/src/styles/StyleUtils.ts b/src/styles/StyleUtils.ts index faece4f44335..eda4c3309cbf 100644 --- a/src/styles/StyleUtils.ts +++ b/src/styles/StyleUtils.ts @@ -273,7 +273,8 @@ function getDefaultWorkspaceAvatarColor(workspaceName: string): ViewStyle { * Helper method to return eReceipt color code */ function getEReceiptColorCode(transaction: Transaction): EReceiptColorName { - const transactionID = transaction.parentTransactionID ?? transaction.transactionID ?? ''; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const transactionID = transaction.parentTransactionID || transaction.transactionID || ''; const colorHash = UserUtils.hashText(transactionID.trim(), eReceiptColors.length); @@ -521,9 +522,9 @@ function getBadgeColorStyle(isSuccess: boolean, isError: boolean, isPressed = fa function getButtonBackgroundColorStyle(buttonState: ButtonStateName = CONST.BUTTON_STATES.DEFAULT, isMenuItem = false): ViewStyle { switch (buttonState) { case CONST.BUTTON_STATES.PRESSED: - return {backgroundColor: themeColors.buttonPressedBG}; + return isMenuItem ? {backgroundColor: themeColors.border} : {backgroundColor: themeColors.buttonPressedBG}; case CONST.BUTTON_STATES.ACTIVE: - return isMenuItem ? {backgroundColor: themeColors.border} : {backgroundColor: themeColors.buttonHoveredBG}; + return isMenuItem ? {backgroundColor: themeColors.highlightBG} : {backgroundColor: themeColors.buttonHoveredBG}; case CONST.BUTTON_STATES.DISABLED: case CONST.BUTTON_STATES.DEFAULT: default: diff --git a/src/styles/ThemeStylesContext.ts b/src/styles/ThemeStylesContext.ts index 1c81ab3b39a5..3df2b19b31bf 100644 --- a/src/styles/ThemeStylesContext.ts +++ b/src/styles/ThemeStylesContext.ts @@ -1,6 +1,6 @@ import React from 'react'; -import styles from './styles'; +import styles, {type Styles} from './styles'; -const ThemeStylesContext = React.createContext(styles); +const ThemeStylesContext = React.createContext(styles); export default ThemeStylesContext; diff --git a/src/styles/ThemeStylesProvider.tsx b/src/styles/ThemeStylesProvider.tsx index 25ce1f58b65e..7f26422e98ce 100644 --- a/src/styles/ThemeStylesProvider.tsx +++ b/src/styles/ThemeStylesProvider.tsx @@ -1,12 +1,9 @@ /* eslint-disable react/jsx-props-no-spreading */ import React, {useMemo} from 'react'; -// TODO: Rename this to "styles" once the app is migrated to theme switching hooks and HOCs -import {stylesGenerator as stylesUntyped} from './styles'; +import {stylesGenerator} from './styles'; import useTheme from './themes/useTheme'; import ThemeStylesContext from './ThemeStylesContext'; -const styles = stylesUntyped; - type ThemeStylesProviderProps = { children: React.ReactNode; }; @@ -14,7 +11,7 @@ type ThemeStylesProviderProps = { function ThemeStylesProvider({children}: ThemeStylesProviderProps) { const theme = useTheme(); - const themeStyles = useMemo(() => styles(theme), [theme]); + const themeStyles = useMemo(() => stylesGenerator(theme), [theme]); return {children}; } diff --git a/src/styles/colors.js b/src/styles/colors.ts similarity index 85% rename from src/styles/colors.js rename to src/styles/colors.ts index 9ac3226a1b80..fbe694e051ee 100644 --- a/src/styles/colors.js +++ b/src/styles/colors.ts @@ -1,7 +1,12 @@ +import {Color} from './themes/types'; + /** - * DO NOT import colors.js into files. Use ../themes/default.js instead. + * DO NOT import colors.js into files. Use the theme switching hooks and HOCs instead. + * For functional components, you can use the `useTheme` and `useThemeStyles` hooks + * For class components, you can use the `withTheme` and `withThemeStyles` HOCs */ -export default { +const colors: Record = { + // Brand Colors black: '#000000', white: '#FFFFFF', ivory: '#fffaf0', @@ -91,3 +96,5 @@ export default { ice700: '#28736D', ice800: '#134038', }; + +export default colors; diff --git a/src/styles/styles.ts b/src/styles/styles.ts index e5b1c2118264..7194a574d500 100644 --- a/src/styles/styles.ts +++ b/src/styles/styles.ts @@ -20,7 +20,7 @@ import overflowXHidden from './overflowXHidden'; import pointerEventsAuto from './pointerEventsAuto'; import pointerEventsNone from './pointerEventsNone'; import defaultTheme from './themes/default'; -import {ThemeDefault} from './themes/types'; +import {ThemeColors} from './themes/types'; import borders from './utilities/borders'; import cursor from './utilities/cursor'; import display from './utilities/display'; @@ -81,7 +81,7 @@ const touchCalloutNone: Pick = Browser.isMobile // to prevent vertical text offset in Safari for badges, new lineHeight values have been added const lineHeightBadge: Pick = Browser.isSafari() ? {lineHeight: variables.lineHeightXSmall} : {lineHeight: variables.lineHeightNormal}; -const picker = (theme: ThemeDefault) => +const picker = (theme: ThemeColors) => ({ backgroundColor: theme.transparent, color: theme.text, @@ -97,14 +97,14 @@ const picker = (theme: ThemeDefault) => textAlign: 'left', } satisfies TextStyle); -const link = (theme: ThemeDefault) => +const link = (theme: ThemeColors) => ({ color: theme.link, textDecorationColor: theme.link, fontFamily: fontFamily.EXP_NEUE, } satisfies ViewStyle & MixedStyleDeclaration); -const baseCodeTagStyles = (theme: ThemeDefault) => +const baseCodeTagStyles = (theme: ThemeColors) => ({ borderWidth: 1, borderRadius: 5, @@ -117,7 +117,7 @@ const headlineFont = { fontWeight: '500', } satisfies TextStyle; -const webViewStyles = (theme: ThemeDefault) => +const webViewStyles = (theme: ThemeColors) => ({ // As of react-native-render-html v6, don't declare distinct styles for // custom renderers, the API for custom renderers has changed. Declare the @@ -212,7 +212,7 @@ const webViewStyles = (theme: ThemeDefault) => }, } satisfies WebViewStyle); -const styles = (theme: ThemeDefault) => +const styles = (theme: ThemeColors) => ({ // Add all of our utility and helper styles ...spacing, @@ -4010,12 +4010,8 @@ const styles = (theme: ThemeDefault) => }, } satisfies Styles); -// For now we need to export the styles function that takes the theme as an argument -// as something named different than "styles", because a lot of files import the "defaultStyles" -// as "styles", which causes ESLint to throw an error. -// TODO: Remove "stylesGenerator" and instead only return "styles" once the app is migrated to theme switching hooks and HOCs and "styles/theme/default.js" is not used anywhere anymore (GH issue: https://github.com/Expensify/App/issues/27337) const stylesGenerator = styles; const defaultStyles = styles(defaultTheme); export default defaultStyles; -export {stylesGenerator}; +export {stylesGenerator, type Styles}; diff --git a/src/styles/themes/ThemeContext.js b/src/styles/themes/ThemeContext.js deleted file mode 100644 index 30d476c22d9c..000000000000 --- a/src/styles/themes/ThemeContext.js +++ /dev/null @@ -1,6 +0,0 @@ -import React from 'react'; -import defaultColors from './default'; - -const ThemeContext = React.createContext(defaultColors); - -export default ThemeContext; diff --git a/src/styles/themes/ThemeContext.ts b/src/styles/themes/ThemeContext.ts new file mode 100644 index 000000000000..8c57cc9c7e9f --- /dev/null +++ b/src/styles/themes/ThemeContext.ts @@ -0,0 +1,7 @@ +import React from 'react'; +import darkTheme from './default'; +import {ThemeColors} from './types'; + +const ThemeContext = React.createContext(darkTheme); + +export default ThemeContext; diff --git a/src/styles/themes/ThemeProvider.js b/src/styles/themes/ThemeProvider.tsx similarity index 80% rename from src/styles/themes/ThemeProvider.js rename to src/styles/themes/ThemeProvider.tsx index 58d0baedbe06..50bfb3b045f4 100644 --- a/src/styles/themes/ThemeProvider.js +++ b/src/styles/themes/ThemeProvider.tsx @@ -2,8 +2,8 @@ import PropTypes from 'prop-types'; import React, {useMemo} from 'react'; import CONST from '@src/CONST'; -// Going to eventually import the light theme here too import darkTheme from './default'; +import lightTheme from './light'; import ThemeContext from './ThemeContext'; import useThemePreference from './useThemePreference'; @@ -12,10 +12,10 @@ const propTypes = { children: PropTypes.node.isRequired, }; -function ThemeProvider(props) { +function ThemeProvider(props: React.PropsWithChildren) { const themePreference = useThemePreference(); - const theme = useMemo(() => (themePreference === CONST.THEME.LIGHT ? /* TODO: replace with light theme */ darkTheme : darkTheme), [themePreference]); + const theme = useMemo(() => (themePreference === CONST.THEME.LIGHT ? lightTheme : darkTheme), [themePreference]); return {props.children}; } diff --git a/src/styles/themes/default.ts b/src/styles/themes/default.ts index 98ff8773fb51..dd92b1ce71d9 100644 --- a/src/styles/themes/default.ts +++ b/src/styles/themes/default.ts @@ -1,6 +1,6 @@ import colors from '@styles/colors'; import SCREENS from '@src/SCREENS'; -import type {ThemeBase} from './types'; +import {ThemeColors} from './types'; const darkTheme = { // Figma keys @@ -83,19 +83,18 @@ const darkTheme = { starDefaultBG: 'rgb(254, 228, 94)', loungeAccessOverlay: colors.blue800, mapAttributionText: colors.black, - PAGE_BACKGROUND_COLORS: {}, white: colors.white, -} satisfies ThemeBase; -darkTheme.PAGE_BACKGROUND_COLORS = { - [SCREENS.HOME]: darkTheme.sidebar, - [SCREENS.SAVE_THE_WORLD.ROOT]: colors.tangerine800, - [SCREENS.SETTINGS.PREFERENCES]: colors.blue500, - [SCREENS.SETTINGS.WORKSPACES]: colors.pink800, - [SCREENS.SETTINGS.WALLET]: colors.darkAppBackground, - [SCREENS.SETTINGS.SECURITY]: colors.ice500, - [SCREENS.SETTINGS.STATUS]: colors.green700, - [SCREENS.SETTINGS.ROOT]: darkTheme.sidebar, -}; + PAGE_BACKGROUND_COLORS: { + [SCREENS.HOME]: colors.darkHighlightBackground, + [SCREENS.SAVE_THE_WORLD.ROOT]: colors.tangerine800, + [SCREENS.SETTINGS.PREFERENCES]: colors.blue500, + [SCREENS.SETTINGS.WORKSPACES]: colors.pink800, + [SCREENS.SETTINGS.WALLET]: colors.darkAppBackground, + [SCREENS.SETTINGS.SECURITY]: colors.ice500, + [SCREENS.SETTINGS.STATUS]: colors.green700, + [SCREENS.SETTINGS.ROOT]: colors.darkHighlightBackground, + }, +} satisfies ThemeColors; export default darkTheme; diff --git a/src/styles/themes/light.ts b/src/styles/themes/light.ts index 624c7df0caa8..97fe2322945a 100644 --- a/src/styles/themes/light.ts +++ b/src/styles/themes/light.ts @@ -1,6 +1,6 @@ import colors from '@styles/colors'; import SCREENS from '@src/SCREENS'; -import type {ThemeDefault} from './types'; +import {ThemeColors} from './types'; const lightTheme = { // Figma keys @@ -16,9 +16,9 @@ const lightTheme = { iconSuccessFill: colors.green400, iconReversed: colors.lightAppBackground, iconColorfulBackground: `${colors.ivory}cc`, - textColorfulBackground: colors.ivory, textSupporting: colors.lightSupportingText, text: colors.lightPrimaryText, + textColorfulBackground: colors.ivory, link: colors.blue600, linkHover: colors.blue500, buttonDefaultBG: colors.lightDefaultButton, @@ -83,19 +83,18 @@ const lightTheme = { starDefaultBG: 'rgb(254, 228, 94)', loungeAccessOverlay: colors.blue800, mapAttributionText: colors.black, - PAGE_BACKGROUND_COLORS: {}, white: colors.white, -} satisfies ThemeDefault; -lightTheme.PAGE_BACKGROUND_COLORS = { - [SCREENS.HOME]: lightTheme.sidebar, - [SCREENS.SAVE_THE_WORLD.ROOT]: colors.tangerine800, - [SCREENS.SETTINGS.PREFERENCES]: colors.blue500, - [SCREENS.SETTINGS.WALLET]: colors.darkAppBackground, - [SCREENS.SETTINGS.WORKSPACES]: colors.pink800, - [SCREENS.SETTINGS.SECURITY]: colors.ice500, - [SCREENS.SETTINGS.STATUS]: colors.green700, - [SCREENS.SETTINGS.ROOT]: lightTheme.sidebar, -}; + PAGE_BACKGROUND_COLORS: { + [SCREENS.HOME]: colors.lightHighlightBackground, + [SCREENS.SAVE_THE_WORLD.ROOT]: colors.tangerine800, + [SCREENS.SETTINGS.PREFERENCES]: colors.blue500, + [SCREENS.SETTINGS.WORKSPACES]: colors.pink800, + [SCREENS.SETTINGS.WALLET]: colors.darkAppBackground, + [SCREENS.SETTINGS.SECURITY]: colors.ice500, + [SCREENS.SETTINGS.STATUS]: colors.green700, + [SCREENS.SETTINGS.ROOT]: colors.lightHighlightBackground, + }, +} satisfies ThemeColors; export default lightTheme; diff --git a/src/styles/themes/types.ts b/src/styles/themes/types.ts index 59e8001d29fe..4064dd289650 100644 --- a/src/styles/themes/types.ts +++ b/src/styles/themes/types.ts @@ -1,8 +1,89 @@ -import DeepRecord from '@src/types/utils/DeepRecord'; -import defaultTheme from './default'; +type Color = string; -type ThemeBase = DeepRecord; +type ThemeColors = { + // Figma keys + appBG: Color; + splashBG: Color; + highlightBG: Color; + border: Color; + borderLighter: Color; + borderFocus: Color; + icon: Color; + iconMenu: Color; + iconHovered: Color; + iconSuccessFill: Color; + iconReversed: Color; + iconColorfulBackground: Color; + textSupporting: Color; + text: Color; + textColorfulBackground: Color; + link: Color; + linkHover: Color; + buttonDefaultBG: Color; + buttonHoveredBG: Color; + buttonPressedBG: Color; + danger: Color; + dangerHover: Color; + dangerPressed: Color; + warning: Color; + success: Color; + successHover: Color; + successPressed: Color; + transparent: Color; + signInPage: Color; + dangerSection: Color; -type ThemeDefault = typeof defaultTheme; + // Additional keys + overlay: Color; + inverse: Color; + shadow: Color; + componentBG: Color; + hoverComponentBG: Color; + activeComponentBG: Color; + signInSidebar: Color; + sidebar: Color; + sidebarHover: Color; + heading: Color; + textLight: Color; + textDark: Color; + textReversed: Color; + textBackground: Color; + textMutedReversed: Color; + textError: Color; + offline: Color; + modalBackground: Color; + cardBG: Color; + cardBorder: Color; + spinner: Color; + unreadIndicator: Color; + placeholderText: Color; + heroCard: Color; + uploadPreviewActivityIndicator: Color; + dropUIBG: Color; + receiptDropUIBG: Color; + checkBox: Color; + pickerOptionsTextColor: Color; + imageCropBackgroundColor: Color; + fallbackIconColor: Color; + reactionActiveBackground: Color; + reactionActiveText: Color; + badgeAdHoc: Color; + badgeAdHocHover: Color; + mentionText: Color; + mentionBG: Color; + ourMentionText: Color; + ourMentionBG: Color; + tooltipSupportingText: Color; + tooltipPrimaryText: Color; + skeletonLHNIn: Color; + skeletonLHNOut: Color; + QRLogo: Color; + starDefaultBG: Color; + loungeAccessOverlay: Color; + mapAttributionText: Color; + white: Color; -export type {ThemeBase, ThemeDefault}; + PAGE_BACKGROUND_COLORS: Record; +}; + +export {type ThemeColors, type Color}; diff --git a/src/styles/themes/useTheme.js b/src/styles/themes/useTheme.ts similarity index 50% rename from src/styles/themes/useTheme.js rename to src/styles/themes/useTheme.ts index 8e88b23a7688..8bb4fe73c106 100644 --- a/src/styles/themes/useTheme.js +++ b/src/styles/themes/useTheme.ts @@ -1,11 +1,12 @@ import {useContext} from 'react'; import ThemeContext from './ThemeContext'; +import {ThemeColors} from './types'; -function useTheme() { +function useTheme(): ThemeColors { const theme = useContext(ThemeContext); if (!theme) { - throw new Error('StylesContext was null! Are you sure that you wrapped the component under a ?'); + throw new Error('ThemeContext was null! Are you sure that you wrapped the component under a ?'); } return theme; diff --git a/src/styles/themes/useThemePreference.js b/src/styles/themes/useThemePreference.ts similarity index 58% rename from src/styles/themes/useThemePreference.js rename to src/styles/themes/useThemePreference.ts index 8c26ad931d6d..ac6ac02933c7 100644 --- a/src/styles/themes/useThemePreference.js +++ b/src/styles/themes/useThemePreference.ts @@ -1,29 +1,31 @@ import {useContext, useEffect, useState} from 'react'; -import {Appearance} from 'react-native'; +import {Appearance, ColorSchemeName} from 'react-native'; import {PreferredThemeContext} from '@components/OnyxProvider'; import CONST from '@src/CONST'; +type ThemePreference = typeof CONST.THEME.LIGHT | typeof CONST.THEME.DARK; + function useThemePreference() { - const [themePreference, setThemePreference] = useState(CONST.THEME.DEFAULT); - const [systemTheme, setSystemTheme] = useState(); - const preferredThemeContext = useContext(PreferredThemeContext); + const [themePreference, setThemePreference] = useState(CONST.THEME.DEFAULT); + const [systemTheme, setSystemTheme] = useState(); + const preferredThemeFromStorage = useContext(PreferredThemeContext); useEffect(() => { // This is used for getting the system theme, that can be set in the OS's theme settings. This will always return either "light" or "dark" and will update automatically if the OS theme changes. const systemThemeSubscription = Appearance.addChangeListener(({colorScheme}) => setSystemTheme(colorScheme)); - return systemThemeSubscription.remove; + return () => systemThemeSubscription.remove(); }, []); useEffect(() => { - const theme = preferredThemeContext || CONST.THEME.DEFAULT; + const theme = preferredThemeFromStorage ?? CONST.THEME.DEFAULT; // If the user chooses to use the device theme settings, we need to set the theme preference to the system theme if (theme === CONST.THEME.SYSTEM) { - setThemePreference(systemTheme); + setThemePreference(systemTheme ?? CONST.THEME.DEFAULT); } else { setThemePreference(theme); } - }, [preferredThemeContext, systemTheme]); + }, [preferredThemeFromStorage, systemTheme]); return themePreference; } diff --git a/src/styles/useThemeStyles.ts b/src/styles/useThemeStyles.ts index a5b3baebbaec..69ba43692f49 100644 --- a/src/styles/useThemeStyles.ts +++ b/src/styles/useThemeStyles.ts @@ -5,7 +5,7 @@ function useThemeStyles() { const themeStyles = useContext(ThemeStylesContext); if (!themeStyles) { - throw new Error('StylesContext was null! Are you sure that you wrapped the component under a ?'); + throw new Error('ThemeStylesContext was null! Are you sure that you wrapped the component under a ?'); } // TODO: Remove this "eslint-disable-next" once the theme switching migration is done and styles are fully typed (GH Issue: https://github.com/Expensify/App/issues/27337) diff --git a/src/types/modules/react-native-web-linear-gradient.d.ts b/src/types/modules/react-native-web-linear-gradient.d.ts new file mode 100644 index 000000000000..6909ce3dbde2 --- /dev/null +++ b/src/types/modules/react-native-web-linear-gradient.d.ts @@ -0,0 +1,6 @@ +/* eslint-disable @typescript-eslint/consistent-type-definitions */ +declare module 'react-native-web-linear-gradient' { + import LinearGradient from 'react-native-linear-gradient'; + + export default LinearGradient; +} diff --git a/src/types/onyx/Form.ts b/src/types/onyx/Form.ts index cda8c3c1017e..7b7d8d76536a 100644 --- a/src/types/onyx/Form.ts +++ b/src/types/onyx/Form.ts @@ -16,6 +16,11 @@ type AddDebitCardForm = Form & { setupComplete: boolean; }; +type DateOfBirthForm = Form & { + /** Date of birth */ + dob?: string; +}; + export default Form; -export type {AddDebitCardForm}; +export type {AddDebitCardForm, DateOfBirthForm}; diff --git a/src/types/onyx/Login.ts b/src/types/onyx/Login.ts index 60ea5985315e..c770e2f81f90 100644 --- a/src/types/onyx/Login.ts +++ b/src/types/onyx/Login.ts @@ -14,7 +14,7 @@ type Login = { errorFields?: OnyxCommon.ErrorFields; /** Field-specific pending states for offline UI status */ - pendingFields?: OnyxCommon.ErrorFields; + pendingFields?: OnyxCommon.PendingFields; }; export default Login; diff --git a/src/types/onyx/OnyxCommon.ts b/src/types/onyx/OnyxCommon.ts index bafd5e8cbbf0..ef2944d6af82 100644 --- a/src/types/onyx/OnyxCommon.ts +++ b/src/types/onyx/OnyxCommon.ts @@ -4,7 +4,9 @@ import CONST from '@src/CONST'; type PendingAction = ValueOf; -type ErrorFields = Record | null>; +type PendingFields = Record; + +type ErrorFields = Record; type Errors = Record; @@ -14,4 +16,4 @@ type Icon = { name: string; }; -export type {Icon, PendingAction, ErrorFields, Errors}; +export type {Icon, PendingAction, PendingFields, ErrorFields, Errors}; diff --git a/src/types/onyx/PersonalDetails.ts b/src/types/onyx/PersonalDetails.ts index 0cd264802128..5637d7e5fdcf 100644 --- a/src/types/onyx/PersonalDetails.ts +++ b/src/types/onyx/PersonalDetails.ts @@ -1,3 +1,5 @@ +import * as OnyxCommon from './OnyxCommon'; + type Timezone = { /** Value of selected timezone */ selected?: string; @@ -28,6 +30,11 @@ type PersonalDetails = { /** Avatar URL of the current user from their personal details */ avatar: string; + /** Avatar thumbnail URL of the current user from their personal details */ + avatarThumbnail?: string; + + originalFileName?: string; + /** Flag to set when Avatar uploading */ avatarUploading?: boolean; @@ -43,10 +50,21 @@ type PersonalDetails = { /** Timezone of the current user from their personal details */ timezone?: Timezone; + /** Whether we are loading the data via the API */ + isLoading?: boolean; + + /** Field-specific server side errors keyed by microtime */ + errorFields?: OnyxCommon.ErrorFields<'avatar'>; + + /** Field-specific pending states for offline UI status */ + pendingFields?: OnyxCommon.PendingFields<'avatar' | 'originalFileName'>; + + /** A fallback avatar icon to display when there is an error on loading avatar from remote URL. */ + fallbackIcon?: string; /** Status of the current user from their personal details */ + status?: string; }; -export type {Timezone}; - export default PersonalDetails; +export type {Timezone}; diff --git a/src/types/onyx/PrivatePersonalDetails.ts b/src/types/onyx/PrivatePersonalDetails.ts index 50ec77212efd..6ef5b75c4a0f 100644 --- a/src/types/onyx/PrivatePersonalDetails.ts +++ b/src/types/onyx/PrivatePersonalDetails.ts @@ -13,6 +13,9 @@ type PrivatePersonalDetails = { /** User's home address */ address?: Address; + + /** Whether we are loading the data via the API */ + isLoading?: boolean; }; export default PrivatePersonalDetails; diff --git a/src/types/onyx/ReportAction.ts b/src/types/onyx/ReportAction.ts index 19908273ad3d..66622f4b29ea 100644 --- a/src/types/onyx/ReportAction.ts +++ b/src/types/onyx/ReportAction.ts @@ -43,6 +43,9 @@ type Message = { moderationDecision?: Decision; translationKey?: string; + + /** ID of a task report */ + taskReportID?: string; }; type Person = { diff --git a/src/types/onyx/Response.ts b/src/types/onyx/Response.ts index 3d834d0bcb2b..becf244388fc 100644 --- a/src/types/onyx/Response.ts +++ b/src/types/onyx/Response.ts @@ -10,6 +10,7 @@ type Response = { authToken?: string; encryptedAuthToken?: string; message?: string; + shortLivedAuthToken?: string; }; export default Response; diff --git a/src/types/onyx/Transaction.ts b/src/types/onyx/Transaction.ts index de5f2eec9f9d..e52389a72b46 100644 --- a/src/types/onyx/Transaction.ts +++ b/src/types/onyx/Transaction.ts @@ -56,7 +56,7 @@ type Transaction = { created: string; currency: string; errors?: OnyxCommon.Errors; - errorFields?: OnyxCommon.ErrorFields; + errorFields?: OnyxCommon.ErrorFields<'route'>; // The name of the file used for a receipt (formerly receiptFilename) filename?: string; merchant: string; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index 93a19b39aad3..f02d3d2f548f 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -8,7 +8,7 @@ import Credentials from './Credentials'; import Currency from './Currency'; import CustomStatusDraft from './CustomStatusDraft'; import Download from './Download'; -import Form, {AddDebitCardForm} from './Form'; +import Form, {AddDebitCardForm, DateOfBirthForm} from './Form'; import FrequentlyUsedEmoji from './FrequentlyUsedEmoji'; import Fund from './Fund'; import IOU from './IOU'; @@ -51,58 +51,59 @@ import WalletTransfer from './WalletTransfer'; export type { Account, - Request, + AccountData, + AddDebitCardForm, + BankAccount, + Beta, + BlockedFromConcierge, + Card, Credentials, + Currency, + CustomStatusDraft, + DateOfBirthForm, + Download, + Form, + FrequentlyUsedEmoji, + Fund, IOU, + Login, + MapboxAccessToken, Modal, Network, - CustomStatusDraft, + OnyxUpdateEvent, + OnyxUpdatesFromServer, + PersonalBankAccount, PersonalDetails, - PrivatePersonalDetails, - Task, - Currency, - ScreenShareRequest, - User, - Login, - Session, - Beta, - BlockedFromConcierge, PlaidData, - UserWallet, - WalletOnfido, - WalletAdditionalDetails, - WalletTerms, - BankAccount, - Card, - Fund, - WalletStatement, - PersonalBankAccount, - ReimbursementAccount, - ReimbursementAccountDraft, - FrequentlyUsedEmoji, - WalletTransfer, - MapboxAccessToken, - Download, - PolicyMember, Policy, PolicyCategory, + PolicyMember, + PolicyMembers, + PolicyTag, + PolicyTags, + PrivatePersonalDetails, + RecentlyUsedCategories, + RecentlyUsedTags, + RecentWaypoint, + ReimbursementAccount, + ReimbursementAccountDraft, Report, - ReportMetadata, ReportAction, + ReportActionReactions, ReportActions, ReportActionsDrafts, - ReportActionReactions, + ReportMetadata, + Request, + ScreenShareRequest, SecurityGroup, + Session, + Task, Transaction, - Form, - AddDebitCardForm, - OnyxUpdatesFromServer, - RecentWaypoint, - OnyxUpdateEvent, - RecentlyUsedCategories, - RecentlyUsedTags, - PolicyTag, - PolicyTags, - PolicyMembers, - AccountData, + User, + UserWallet, + WalletAdditionalDetails, + WalletOnfido, + WalletStatement, + WalletTerms, + WalletTransfer, }; diff --git a/tests/e2e/ADDING_TESTS.md b/tests/e2e/ADDING_TESTS.md index dcd08aeee441..6a4ea3edd1ef 100644 --- a/tests/e2e/ADDING_TESTS.md +++ b/tests/e2e/ADDING_TESTS.md @@ -2,22 +2,27 @@ ## Running your new test in development mode -Typically you'd run all the tests with `npm run test:e2e` on your machine, -this will run the tests with some local settings, however that is not -optimal when you add a new test for which you want to quickly test if it works, as it -still runs the release version of the app. +Typically you'd run all the tests with `npm run test:e2e` on your machine. +This will run the tests with some local settings, however that is not +optimal when you add a new test for which you want to quickly test if it works, as the prior command +still runs the release version of the app, which is hard to debug. I recommend doing the following. -> [!NOTE] -> All of the steps can be executed at once by running XXX (todo) +1. We need to compile a android development app version that has capturing metrics enabled: +```bash +# Make sure that your .env file is the one we need for e2e testing: +cp ./tests/e2e/.env.e2e .env -1. Rename `./index.js` to `./appIndex.js` -2. Create a new `./index.js` with the following content: +# Build the android app like you normally would with +npm run android +``` +2. Rename `./index.js` to `./appIndex.js` +3. Create a new `./index.js` with the following content: ```js -requrire("./src/libs/E2E/reactNativeLaunchingTest.js"); +require('./src/libs/E2E/reactNativeLaunchingTest'); ``` -3. In `./src/libs/E2E/reactNativeLaunchingTest.js` change the main app import to the new `./appIndex.js` file: +4. In `./src/libs/E2E/reactNativeLaunchingTest.js` change the main app import to the new `./appIndex.js` file: ```diff - import '../../../index'; + import '../../../appIndex'; @@ -28,21 +33,17 @@ requrire("./src/libs/E2E/reactNativeLaunchingTest.js"); Now you can start the metro bundler in e2e mode with: -``` -CAPTURE_METRICS=TRUE E2E_Testing=true npm start -- --reset-cache +```bash +CAPTURE_METRICS=true E2E_TESTING=true npm start -- --reset-cache ``` Then we can execute our test with: ``` -npm run test:e2e -- --development --skipInstallDeps --buildMode skip --includes "My new test name" +npm run test:e2e:dev -- --includes "My new test name" ``` -> - `--development` will run the tests with a local config, which will run the tests with fewer iterations -> - `--skipInstallDeps` will skip the `npm install` step, which you probably don't need -> - `--buildMode skip` will skip rebuilding the app, and just run the existing app -> - `--includes "MyTestName"` will only run the test with the name "MyTestName" - +> - `--includes "MyTestName"` will only run the test with the name "MyTestName", but is optional ## Creating a new test diff --git a/tests/e2e/config.dev.js b/tests/e2e/config.dev.js new file mode 100644 index 000000000000..46191ebdee48 --- /dev/null +++ b/tests/e2e/config.dev.js @@ -0,0 +1,5 @@ +module.exports = { + APP_PACKAGE: 'com.expensify.chat.dev', + APP_PATH: './android/app/build/outputs/apk/development/debug/app-development-debug.apk', + RUNS: 8, +}; diff --git a/tests/e2e/config.js b/tests/e2e/config.js index 1e73aa58d3d9..c466000d0b53 100644 --- a/tests/e2e/config.js +++ b/tests/e2e/config.js @@ -9,6 +9,7 @@ const OUTPUT_DIR = process.env.WORKING_DIRECTORY || './tests/e2e/results'; const TEST_NAMES = { AppStartTime: 'App start time', OpenSearchPage: 'Open search page TTI', + ReportTyping: 'Report typing', }; /** @@ -69,5 +70,11 @@ module.exports = { [TEST_NAMES.OpenSearchPage]: { name: TEST_NAMES.OpenSearchPage, }, + [TEST_NAMES.ReportTyping]: { + name: TEST_NAMES.ReportTyping, + reportScreen: { + autoFocus: true, + }, + }, }, }; diff --git a/tests/e2e/config.local.js b/tests/e2e/config.local.js index 15b091d8ba70..8cdfc50ac625 100644 --- a/tests/e2e/config.local.js +++ b/tests/e2e/config.local.js @@ -1,5 +1,5 @@ module.exports = { APP_PACKAGE: 'com.expensify.chat.adhoc', APP_PATH: './android/app/build/outputs/apk/e2e/release/app-e2e-release.apk', - RUNS: 8, + RUNS: 4, }; diff --git a/tests/e2e/nativeCommands/NativeCommandsAction.js b/tests/e2e/nativeCommands/NativeCommandsAction.js new file mode 100644 index 000000000000..f2aa4644f7ff --- /dev/null +++ b/tests/e2e/nativeCommands/NativeCommandsAction.js @@ -0,0 +1,22 @@ +const NativeCommandsAction = { + scroll: 'scroll', + type: 'type', + backspace: 'backspace', +}; + +const makeTypeTextCommand = (text) => ({ + actionName: NativeCommandsAction.type, + payload: { + text, + }, +}); + +const makeBackspaceCommand = () => ({ + actionName: NativeCommandsAction.backspace, +}); + +module.exports = { + NativeCommandsAction, + makeTypeTextCommand, + makeBackspaceCommand, +}; diff --git a/tests/e2e/nativeCommands/adbBackspace.js b/tests/e2e/nativeCommands/adbBackspace.js new file mode 100644 index 000000000000..8f41364daed3 --- /dev/null +++ b/tests/e2e/nativeCommands/adbBackspace.js @@ -0,0 +1,10 @@ +const execAsync = require('../utils/execAsync'); +const Logger = require('../utils/logger'); + +const adbBackspace = async () => { + Logger.log(`🔙 Pressing backspace`); + execAsync(`adb shell input keyevent KEYCODE_DEL`); + return true; +}; + +module.exports = adbBackspace; diff --git a/tests/e2e/nativeCommands/adbTypeText.js b/tests/e2e/nativeCommands/adbTypeText.js new file mode 100644 index 000000000000..cbaa9f4434a2 --- /dev/null +++ b/tests/e2e/nativeCommands/adbTypeText.js @@ -0,0 +1,10 @@ +const execAsync = require('../utils/execAsync'); +const Logger = require('../utils/logger'); + +const adbTypeText = async (text) => { + Logger.log(`📝 Typing text: ${text}`); + execAsync(`adb shell input text "${text}"`); + return true; +}; + +module.exports = adbTypeText; diff --git a/tests/e2e/nativeCommands/index.js b/tests/e2e/nativeCommands/index.js new file mode 100644 index 000000000000..bb87c16a6f42 --- /dev/null +++ b/tests/e2e/nativeCommands/index.js @@ -0,0 +1,22 @@ +const adbBackspace = require('./adbBackspace'); +const adbTypeText = require('./adbTypeText'); +const {NativeCommandsAction} = require('./NativeCommandsAction'); + +const executeFromPayload = (actionName, payload) => { + switch (actionName) { + case NativeCommandsAction.scroll: + throw new Error('Not implemented yet'); + case NativeCommandsAction.type: + return adbTypeText(payload.text); + case NativeCommandsAction.backspace: + return adbBackspace(); + default: + throw new Error(`Unknown action: ${actionName}`); + } +}; + +module.exports = { + NativeCommandsAction, + executeFromPayload, + adbTypeText, +}; diff --git a/tests/e2e/server/index.js b/tests/e2e/server/index.js index 3910ef43f798..4c2e00126fd5 100644 --- a/tests/e2e/server/index.js +++ b/tests/e2e/server/index.js @@ -2,6 +2,7 @@ const {createServer} = require('http'); const Routes = require('./routes'); const Logger = require('../utils/logger'); const {SERVER_PORT} = require('../config'); +const {executeFromPayload} = require('../nativeCommands'); const PORT = process.env.PORT || SERVER_PORT; @@ -125,6 +126,26 @@ const createServerInstance = () => { return res.end('ok'); } + case Routes.testNativeCommand: { + getPostJSONRequestData(req, res) + .then((data) => + executeFromPayload(data.actionName, data.payload).then((status) => { + if (status) { + res.end('ok'); + return; + } + res.statusCode = 500; + res.end('Error executing command'); + }), + ) + .catch((error) => { + Logger.error('Error executing command', error); + res.statusCode = 500; + res.end('Error executing command'); + }); + break; + } + default: res.statusCode = 404; res.end('Page not found!'); diff --git a/tests/e2e/server/routes.js b/tests/e2e/server/routes.js index 5aac2fef4dc2..84fc2f89fd9b 100644 --- a/tests/e2e/server/routes.js +++ b/tests/e2e/server/routes.js @@ -7,4 +7,7 @@ module.exports = { // When the app is done running a test it calls this endpoint testDone: '/test_done', + + // Commands to execute from the host machine (there are pre-defined types like scroll or type) + testNativeCommand: '/test_native_command', }; diff --git a/tests/e2e/testRunner.js b/tests/e2e/testRunner.js index d8e4afd606ac..54cde8f5b336 100644 --- a/tests/e2e/testRunner.js +++ b/tests/e2e/testRunner.js @@ -171,20 +171,28 @@ const runTests = async () => { const server = createServerInstance(); await server.start(); - // Create a dict in which we will store the run durations for all tests - const durationsByTestName = {}; + // Create a dict in which we will store the collected metrics for all tests + const resultsByTestName = {}; // Collect results while tests are being executed server.addTestResultListener((testResult) => { if (testResult.error != null) { throw new Error(`Test '${testResult.name}' failed with error: ${testResult.error}`); } - if (testResult.duration < 0) { - return; + let result = 0; + + if ('duration' in testResult) { + if (testResult.duration < 0) { + return; + } + result = testResult.duration; + } + if ('renderCount' in testResult) { + result = testResult.renderCount; } - Logger.log(`[LISTENER] Test '${testResult.name}' took ${testResult.duration}ms`); - durationsByTestName[testResult.name] = (durationsByTestName[testResult.name] || []).concat(testResult.duration); + Logger.log(`[LISTENER] Test '${testResult.name}' measured ${result}`); + resultsByTestName[testResult.name] = (resultsByTestName[testResult.name] || []).concat(result); }); // Run the tests @@ -275,8 +283,8 @@ const runTests = async () => { // Calculate statistics and write them to our work file progressLog = Logger.progressInfo('Calculating statics and writing results'); - for (const testName of _.keys(durationsByTestName)) { - const stats = math.getStats(durationsByTestName[testName]); + for (const testName of _.keys(resultsByTestName)) { + const stats = math.getStats(resultsByTestName[testName]); await writeTestStats( { name: testName, diff --git a/tsconfig.json b/tsconfig.json index 151087fb1321..eafc7c375fdd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,7 +18,8 @@ "es2021.weakref", "es2022.array", "es2022.object", - "es2022.string" + "es2022.string", + "ES2021.Intl" ], "allowJs": true, "checkJs": false,