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 e43909433367..aa329c3ae935 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 1001039504 - versionName "1.3.95-4" + versionCode 1001039506 + versionName "1.3.95-6" } flavorDimensions "default" diff --git a/assets/css/fonts.css b/assets/css/fonts.css index 7834a0ebb861..078cec114c31 100644 --- a/assets/css/fonts.css +++ b/assets/css/fonts.css @@ -54,6 +54,11 @@ src: url('/fonts/ExpensifyNewKansas-MediumItalic.woff2') format('woff2'), url('/fonts/ExpensifyNewKansas-MediumItalic.woff') format('woff'); } +@font-face { + font-family: Windows Segoe UI Emoji; + src: url('/fonts/seguiemj.ttf'); +} + * { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; diff --git a/assets/fonts/web/seguiemj.ttf b/assets/fonts/web/seguiemj.ttf new file mode 100644 index 000000000000..3a455801aa0c Binary files /dev/null and b/assets/fonts/web/seguiemj.ttf differ 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/bank-accounts-and-credit-cards/company-cards/CSV-Import.md b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/CSV-Import.md index 6debce6240ff..fc1e83701caf 100644 --- a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/CSV-Import.md +++ b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/CSV-Import.md @@ -1,5 +1,101 @@ --- -title: CSV Import -description: CSV Import +title: Import and assign company cards from CSV file +description: uploading a CSV file containing your company card transactions --- -## Resource Coming Soon! + +# Overview +Expensify offers a convenient CSV import feature for managing company card expenses when direct connections or commercial card feeds aren't available. This feature allows you to upload a CSV file containing your company card transactions and assign them to cardholders within your Expensify domain. +This feature is available on Group Workspaces and requires Domain Admin access. + +# How to import company cards via CSV +1. Download a CSV of transactions from your bank by logging into their website and finding the relevant statement. +2. Format the CSV for upload using [this template](https://s3-us-west-1.amazonaws.com/concierge-responses-expensify-com/uploads%2F1594908368712-Best+Example+CSV+for+Domains.csv) as a guide. +- At a minimum, your file must include the following columns: + - **Card Number** - each number in this column should display at least the last four digits, and you can obscure up to 12 characters +(e.g., 543212XXXXXX12334). + - **Posted Date** - use the YYYY-MM-DD format in this column (and any other date column in your spreadsheet). + - **Merchant** - the name of the individual or business that provided goods or services for the transaction. This is a free-text field. + - **Posted Amount** - use the number format in this column, and indicate negative amounts with parentheses (e.g., (335.98) for -$335.98). + - **Posted Currency** - use currency codes (e.g., USD, GBP, EUR) to indicate the currency of the posted transactions. +- You can also add mapping for Categories and Tags, but those parameters are optional. +3. Log into Expensify on your web browser. +4. Head to Settings > Domains > Domain Name > Company Cards +5. Click Manage/Import CSV +6. Create a Company Card Layout Name for your spreadsheet +7. Click Upload CSV +8. Review the mapping of your spreadsheet to ensure that the Card Number, Date, Merchant, Amount, and Currency match your data. +9. Double-check the Output Preview for any errors and, if needed, refer to the common error solutions listed in the FAQ below. +10. Once the mapping is correct, click Submit Spreadsheet to complete the import. +11. After submitting the spreadsheet, click I'll wait a minute. Then, wait about 1-2 minutes for the import to process. The domain page will refresh once the upload is complete. + +# How to assign new cards +If you're assigning cards via CSV upload for the first time: +1. Head to **Settings > Domains > Domain Name > Company Cards** +2. Find the new CSV feed in the drop-down list underneath **Imported Cards** +3. Click **Assign New Cards** +4. Under **Assign a Card**, enter the relevant info +5. Click **Assign** +From there, transactions will be imported to the cardholder's account, where they can add receipts, code the expenses, and submit them for review and approval. + +# How to upload new expenses for existing assigned cards +There's no need to create a new upload layout for subsequent CSV uploads. Instead, add new expenses to the existing CSV: +1. Head to **Settings > Domains > Domain Name > Company Cards** +2. Click **Manage/Import CSV** +3. Select the saved layout from the drop-down list +4. Click **Upload CSV** +5. After uploading the more recent CSV, click **Update All Cards** to retrieve the new expenses for the assigned cards. + +# Deep dive +If the CSV upload isn't formatted correctly, it will cause issues when you try to import or assign cards. Let's go over some common issues and how to fix them. + +## Error: "Attribute value mapping is missing" +If you encounter an error that says "Attribute-value mapping is missing," the spreadsheet likely lacks critical details like Card Number, Date, Merchant, Amount, or Currency. To resolve: +1. Click the **X** at the top of the page to close the mapping window +2. Confirm what's missing from the spreadsheet +3. Add a new column to your spreadsheet and add the missing detail +4. Upload the revised spreadsheet by clicking **Manage Spreadsheet** +5. Enter a **Company Card Layout Name** for the contents of your spreadsheet +6. Click **Upload CSV** + +## Error: "We've detected an error while processing your spreadsheet feed" +This error usually occurs when there's an upload issue. +To troubleshoot this: +1. Head to **Settings > Domains > Domain Name > Company Cards** and click **Manage/Import CSV** +2. In the **Upload Company Card transactions for** dropdown list, look for the layout name you previously created. +3. If the layout is listed, wait at least one hour and then sync the cards to see if new transactions are imported. +4. If the layout isn't listed, create a new **Company Card Layout Name** and upload the spreadsheet again. + +## Error: "An unexpected error occurred, and we could not retrieve the list of cards" +This error occurs when there's an issue uploading the spreadsheet or the upload fails. +To troubleshoot this: +1. Head to **Settings > Domains > Domain Name > Company Cards** and click **Manage/Import CSV** +2. In the **Upload Company Card transactions for** dropdown list, look for the layout name you previously created. +3. If the layout is listed, wait at least one hour and then sync the cards to see if new transactions are imported. +4. If the layout isn't listed, create a new **Company Card Layout Name** and upload the spreadsheet again. + + +## I added a new parameter to an existing spreadsheet, but the data isn't showing in Expensify after the upload completes. What's going on? +If you added a new card to an existing spreadsheet and imported it via a saved layout, but it isn't showing up for assignment, this suggests that the modification may have caused an issue. +The next step in troubleshooting this issue is to compare the number of rows on the revised spreadsheet to the Output Preview to ensure the row count matches the revised spreadsheet. +To check this: +1. Head to **Settings > Domains > Domain Name > Company Cards** and click **Manage/Import CSV** +2. Select your saved layout in the dropdown list +3. Click **Upload CSV** and select the revised spreadsheet +4. Compare the Output Preview row count to your revised spreadsheet to ensure they match + + +If they don't match, you'll need to revise the spreadsheet by following the CSV formatting guidelines in step 2 of "How to import company cards via CSV" above. +Once you do that, save the revised spreadsheet with a new layout name. +Then, try to upload the revised spreadsheet again: + +1. Click **Upload CSV** +2. Upload the revised file +3. Check the row count again on the Output Preview to confirm it matches the spreadsheet +4. Click **Submit Spreadsheet** + +# FAQ +## Why can't I see my CSV transactions immediately after uploading them? +Don't worry! You'll typically need to wait 1-2 minutes after clicking **I understand, I'll wait!** + +## I'm trying to import a credit. Why isn't it uploading? +Negative expenses shouldn't include a minus sign. Instead, they should just be wrapped in parentheses. For example, to indicate "-335.98," you'll want to make sure it's formatted as "(335.98)." diff --git a/docs/articles/expensify-classic/expense-and-report-features/Attendee-Tracking.md b/docs/articles/expensify-classic/expense-and-report-features/Attendee-Tracking.md index ae367d25891e..7f3d83af1e6e 100644 --- a/docs/articles/expensify-classic/expense-and-report-features/Attendee-Tracking.md +++ b/docs/articles/expensify-classic/expense-and-report-features/Attendee-Tracking.md @@ -11,12 +11,14 @@ Every expense has an Attendees field and will list the expense creator’s name ## How to Add Additional Attendees to an Expense * Go to the attendees field * Search for the names of the attendees - * The default list will be of internal attendees belonging to your workspace and domain. + * The default list will be of internal attendees belonging to your workspace and domain * External attendees are not part of your workspace or domain, so you will need to enter their name or email * Select the attendees you would like to add * Save the expense -* Once added, the list of attendees for each expense will be visible on the expense line. -* An amount per employee expense will also be displayed on the report for easy viewing +* Once added, the list of attendees for each expense will be visible on the expense line +* An amount per employee expense will also be displayed on the report for easy viewing + +![image of an expense with attendee tracking]({{site.url}}/assets/images/attendee-tracking.png){:width="100%"} # FAQ diff --git a/docs/articles/expensify-classic/getting-started/Best-Practices.md b/docs/articles/expensify-classic/getting-started/Best-Practices.md deleted file mode 100644 index b02ea9d68fe6..000000000000 --- a/docs/articles/expensify-classic/getting-started/Best-Practices.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Best Practices -description: Best Practices ---- -## Resource Coming Soon! 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/integrations/HR-integrations/Workday.md b/docs/articles/expensify-classic/integrations/HR-integrations/Workday.md index e9077fc40a50..ecdea4699ee0 100644 --- a/docs/articles/expensify-classic/integrations/HR-integrations/Workday.md +++ b/docs/articles/expensify-classic/integrations/HR-integrations/Workday.md @@ -64,6 +64,8 @@ Before completing the steps below, you will need Workday Report Writer access to - Note: _if there is field data you want to import that is not listed above, or you have any special requests, let your Expensify Account Manager know and we will work with you to accommodate the request._ 4. Rename the columns so they match Expensify's API key names (The full list of names are found here): - employeeID + - customField1 + - customField2 - firstName - lastName - employeeEmail diff --git a/docs/articles/expensify-classic/manage-employees-and-report-approvals/Adding-Users.md b/docs/articles/expensify-classic/manage-employees-and-report-approvals/Add-Members-to-your-Workspace.md similarity index 61% rename from docs/articles/expensify-classic/manage-employees-and-report-approvals/Adding-Users.md rename to docs/articles/expensify-classic/manage-employees-and-report-approvals/Add-Members-to-your-Workspace.md index 3ee1c8656b4b..f2978434959b 100644 --- a/docs/articles/expensify-classic/manage-employees-and-report-approvals/Adding-Users.md +++ b/docs/articles/expensify-classic/manage-employees-and-report-approvals/Add-Members-to-your-Workspace.md @@ -1,5 +1,5 @@ --- -title: Coming Soon +title: Add Members to your Workspace description: Coming Soon --- ## Resource Coming Soon! 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..6853953a5c6b 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 new file mode 100644 index 000000000000..1888851b2a13 Binary files /dev/null and b/docs/assets/images/attendee-tracking.png differ diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 4d019ccacaa1..5cb86992bb4b 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -40,7 +40,7 @@ CFBundleVersion - 1.3.95.4 + 1.3.95.6 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 64aaf1899c16..333b8d1eccff 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -19,6 +19,6 @@ CFBundleSignature ???? CFBundleVersion - 1.3.95.4 + 1.3.95.6 diff --git a/metro.config.js b/metro.config.js index 62ca2a25c6b2..dd391c86c34c 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'), - 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 7c4ba8f2aad7..07d602babc85 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.3.95-4", + "version": "1.3.95-6", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.3.95-4", + "version": "1.3.95-6", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index c18fa7d9da00..13f262d3f8cc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.3.95-4", + "version": "1.3.95-6", "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 29bb0b83aaee..9e7c1f007335 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -1312,6 +1312,7 @@ const CONST = { TAX_ID: /^\d{9}$/, NON_NUMERIC: /\D/g, + ANY_SPACE: /\s/g, // Extract attachment's source from the data's html string ATTACHMENT_DATA: /(data-expensify-source|data-name)="([^"]+)"/g, 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) => ( diff --git a/src/components/DatePicker/index.ios.js b/src/components/DatePicker/index.ios.js index 0f741e8db1ea..60307f70e954 100644 --- a/src/components/DatePicker/index.ios.js +++ b/src/components/DatePicker/index.ios.js @@ -1,5 +1,5 @@ import RNDatePicker from '@react-native-community/datetimepicker'; -import {format} from 'date-fns'; +import {format, parseISO} from 'date-fns'; import isFunction from 'lodash/isFunction'; import React, {useCallback, useEffect, useRef, useState} from 'react'; import {Button, Keyboard, View} from 'react-native'; @@ -77,7 +77,7 @@ function DatePicker({value, defaultValue, innerRef, onInputChange, preferredLoca setSelectedDate(date); }; - const dateAsText = dateValue ? format(new Date(dateValue), CONST.DATE.FNS_FORMAT_STRING) : ''; + const dateAsText = dateValue ? format(parseISO(dateValue), CONST.DATE.FNS_FORMAT_STRING) : ''; return ( <> diff --git a/src/components/DatePicker/index.js b/src/components/DatePicker/index.js index 3bed9ca55321..33266242c5db 100644 --- a/src/components/DatePicker/index.js +++ b/src/components/DatePicker/index.js @@ -1,4 +1,4 @@ -import {format, isValid} from 'date-fns'; +import {format, isValid, parseISO} from 'date-fns'; import React, {useEffect, useRef} from 'react'; import _ from 'underscore'; import TextInput from '@components/TextInput'; @@ -29,7 +29,7 @@ function DatePicker({maxDate, minDate, onInputChange, innerRef, label, value, pl return; } - const date = new Date(text); + const date = parseISO(text); if (isValid(date)) { onInputChange(format(date, CONST.DATE.FNS_FORMAT_STRING)); } diff --git a/src/components/FlatList/MVCPFlatList.js b/src/components/FlatList/MVCPFlatList.js new file mode 100644 index 000000000000..69c6b6767dae --- /dev/null +++ b/src/components/FlatList/MVCPFlatList.js @@ -0,0 +1,205 @@ +/* eslint-disable es/no-optional-chaining, es/no-nullish-coalescing-operators, react/prop-types */ +import PropTypes from 'prop-types'; +import React from 'react'; +import {FlatList} from 'react-native'; + +function mergeRefs(...args) { + return function forwardRef(node) { + args.forEach((ref) => { + if (ref == null) { + return; + } + if (typeof ref === 'function') { + ref(node); + return; + } + if (typeof ref === 'object') { + // eslint-disable-next-line no-param-reassign + ref.current = node; + return; + } + console.error(`mergeRefs cannot handle Refs of type boolean, number or string, received ref ${String(ref)}`); + }); + }; +} + +function useMergeRefs(...args) { + return React.useMemo( + () => mergeRefs(...args), + // eslint-disable-next-line + [...args], + ); +} + +const MVCPFlatList = React.forwardRef(({maintainVisibleContentPosition, horizontal, inverted, onScroll, ...props}, forwardedRef) => { + const {minIndexForVisible: mvcpMinIndexForVisible, autoscrollToTopThreshold: mvcpAutoscrollToTopThreshold} = maintainVisibleContentPosition ?? {}; + const scrollRef = React.useRef(null); + const prevFirstVisibleOffsetRef = React.useRef(null); + const firstVisibleViewRef = React.useRef(null); + const mutationObserverRef = React.useRef(null); + const lastScrollOffsetRef = React.useRef(0); + + const getScrollOffset = React.useCallback(() => { + if (scrollRef.current == null) { + return 0; + } + return horizontal ? scrollRef.current.getScrollableNode().scrollLeft : scrollRef.current.getScrollableNode().scrollTop; + }, [horizontal]); + + const getContentView = React.useCallback(() => scrollRef.current?.getScrollableNode().childNodes[0], []); + + const scrollToOffset = React.useCallback( + (offset, animated) => { + const behavior = animated ? 'smooth' : 'instant'; + scrollRef.current?.getScrollableNode().scroll(horizontal ? {left: offset, behavior} : {top: offset, behavior}); + }, + [horizontal], + ); + + const prepareForMaintainVisibleContentPosition = React.useCallback(() => { + if (mvcpMinIndexForVisible == null) { + return; + } + + const contentView = getContentView(); + if (contentView == null) { + return; + } + + const scrollOffset = getScrollOffset(); + + const contentViewLength = contentView.childNodes.length; + for (let i = mvcpMinIndexForVisible; i < contentViewLength; i++) { + const subview = contentView.childNodes[inverted ? contentViewLength - i - 1 : i]; + const subviewOffset = horizontal ? subview.offsetLeft : subview.offsetTop; + if (subviewOffset > scrollOffset || i === contentViewLength - 1) { + prevFirstVisibleOffsetRef.current = subviewOffset; + firstVisibleViewRef.current = subview; + break; + } + } + }, [getContentView, getScrollOffset, mvcpMinIndexForVisible, horizontal, inverted]); + + const adjustForMaintainVisibleContentPosition = React.useCallback(() => { + if (mvcpMinIndexForVisible == null) { + return; + } + + const firstVisibleView = firstVisibleViewRef.current; + const prevFirstVisibleOffset = prevFirstVisibleOffsetRef.current; + if (firstVisibleView == null || prevFirstVisibleOffset == null) { + return; + } + + const firstVisibleViewOffset = horizontal ? firstVisibleView.offsetLeft : firstVisibleView.offsetTop; + const delta = firstVisibleViewOffset - prevFirstVisibleOffset; + if (Math.abs(delta) > 0.5) { + const scrollOffset = getScrollOffset(); + prevFirstVisibleOffsetRef.current = firstVisibleViewOffset; + scrollToOffset(scrollOffset + delta, false); + if (mvcpAutoscrollToTopThreshold != null && scrollOffset <= mvcpAutoscrollToTopThreshold) { + scrollToOffset(0, true); + } + } + }, [getScrollOffset, scrollToOffset, mvcpMinIndexForVisible, mvcpAutoscrollToTopThreshold, horizontal]); + + const setupMutationObserver = React.useCallback(() => { + const contentView = getContentView(); + if (contentView == null) { + return; + } + + mutationObserverRef.current?.disconnect(); + + const mutationObserver = new MutationObserver(() => { + // Chrome adjusts scroll position when elements are added at the top of the + // view. We want to have the same behavior as react-native / Safari so we + // reset the scroll position to the last value we got from an event. + const lastScrollOffset = lastScrollOffsetRef.current; + const scrollOffset = getScrollOffset(); + if (lastScrollOffset !== scrollOffset) { + scrollToOffset(lastScrollOffset, false); + } + + // This needs to execute after scroll events are dispatched, but + // in the same tick to avoid flickering. rAF provides the right timing. + requestAnimationFrame(() => { + adjustForMaintainVisibleContentPosition(); + }); + }); + mutationObserver.observe(contentView, { + attributes: true, + childList: true, + subtree: true, + }); + + mutationObserverRef.current = mutationObserver; + }, [adjustForMaintainVisibleContentPosition, getContentView, getScrollOffset, scrollToOffset]); + + React.useEffect(() => { + prepareForMaintainVisibleContentPosition(); + setupMutationObserver(); + }, [prepareForMaintainVisibleContentPosition, setupMutationObserver]); + + const setMergedRef = useMergeRefs(scrollRef, forwardedRef); + + const onRef = React.useCallback( + (newRef) => { + // Make sure to only call refs and re-attach listeners if the node changed. + if (newRef == null || newRef === scrollRef.current) { + return; + } + + setMergedRef(newRef); + prepareForMaintainVisibleContentPosition(); + setupMutationObserver(); + }, + [prepareForMaintainVisibleContentPosition, setMergedRef, setupMutationObserver], + ); + + React.useEffect(() => { + const mutationObserver = mutationObserverRef.current; + return () => { + mutationObserver?.disconnect(); + }; + }, []); + + const onScrollInternal = React.useCallback( + (ev) => { + lastScrollOffsetRef.current = getScrollOffset(); + + prepareForMaintainVisibleContentPosition(); + + onScroll?.(ev); + }, + [getScrollOffset, prepareForMaintainVisibleContentPosition, onScroll], + ); + + return ( + + ); +}); + +MVCPFlatList.displayName = 'MVCPFlatList'; +MVCPFlatList.propTypes = { + maintainVisibleContentPosition: PropTypes.shape({ + minIndexForVisible: PropTypes.number.isRequired, + autoscrollToTopThreshold: PropTypes.number, + }), + horizontal: PropTypes.bool, +}; + +MVCPFlatList.defaultProps = { + maintainVisibleContentPosition: null, + horizontal: false, +}; + +export default MVCPFlatList; diff --git a/src/components/FlatList/index.web.js b/src/components/FlatList/index.web.js new file mode 100644 index 000000000000..7299776db9bc --- /dev/null +++ b/src/components/FlatList/index.web.js @@ -0,0 +1,3 @@ +import MVCPFlatList from './MVCPFlatList'; + +export default MVCPFlatList; diff --git a/src/components/Form/FormWrapper.js b/src/components/Form/FormWrapper.js index 55abcc1fc923..f82199d0f587 100644 --- a/src/components/Form/FormWrapper.js +++ b/src/components/Form/FormWrapper.js @@ -56,6 +56,9 @@ const propTypes = { /** Container styles */ style: stylePropTypes, + /** Submit button styles */ + submitButtonStyles: stylePropTypes, + /** Custom content to display in the footer after submit button */ footerContent: PropTypes.oneOfType([PropTypes.func, PropTypes.node]), @@ -74,10 +77,25 @@ const defaultProps = { scrollContextEnabled: false, footerContent: null, style: [], + submitButtonStyles: [], }; function FormWrapper(props) { - const {onSubmit, children, formState, errors, inputRefs, submitButtonText, footerContent, isSubmitButtonVisible, style, enabledWhenOffline, isSubmitActionDangerous, formID} = props; + const { + onSubmit, + children, + formState, + errors, + inputRefs, + submitButtonText, + footerContent, + isSubmitButtonVisible, + style, + submitButtonStyles, + enabledWhenOffline, + isSubmitActionDangerous, + formID, + } = props; const formRef = useRef(null); const formContentRef = useRef(null); const errorMessage = useMemo(() => { @@ -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/InlineErrorText.js b/src/components/InlineErrorText.js deleted file mode 100644 index 80438eea8b5f..000000000000 --- a/src/components/InlineErrorText.js +++ /dev/null @@ -1,31 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import _ from 'underscore'; -import styles from '@styles/styles'; -import Text from './Text'; - -const propTypes = { - /** Text to display */ - children: PropTypes.string.isRequired, - - /** Styling for inline error text */ - // eslint-disable-next-line react/forbid-prop-types - styles: PropTypes.arrayOf(PropTypes.object), -}; - -const defaultProps = { - styles: [], -}; - -function InlineErrorText(props) { - if (_.isEmpty(props.children)) { - return null; - } - - return {props.children}; -} - -InlineErrorText.propTypes = propTypes; -InlineErrorText.defaultProps = defaultProps; -InlineErrorText.displayName = 'InlineErrorText'; -export default InlineErrorText; 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 cb64a135b264..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,53 +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}`, - initialValue: {}, - }, - reportActions: { - key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, - canEvict: false, - initialValue: {}, - }, - personalDetails: { - key: ONYXKEYS.PERSONAL_DETAILS_LIST, - selector: personalDetailsSelector, - initialValue: {}, - }, - 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, - initialValue: {}, - }, - policy: { - key: ({fullReport}) => `${ONYXKEYS.COLLECTION.POLICY}${fullReport.policyID}`, - initialValue: {}, - }, - // 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, initialValue: {}}, - }), - // 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/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) => ( ({isFocused: () => true, addListener: () => () => {}, removeListener: () => () => {}}), []); - - return context ? ( - - ) : ( - - - - ); - } - WithNavigationFallback.displayName = `WithNavigationFocusWithFallback(${getComponentDisplayName(WrappedComponent)})`; - WithNavigationFallback.propTypes = { - forwardedRef: refPropTypes, - }; - WithNavigationFallback.defaultProps = { - forwardedRef: undefined, - }; - - const WithNavigationFallbackWithRef = forwardRef((props, ref) => ( - - )); - - WithNavigationFallbackWithRef.displayName = `WithNavigationFallbackWithRef`; - - return WithNavigationFallbackWithRef; -} diff --git a/src/components/withNavigationFallback.tsx b/src/components/withNavigationFallback.tsx new file mode 100644 index 000000000000..aa58b12d4b01 --- /dev/null +++ b/src/components/withNavigationFallback.tsx @@ -0,0 +1,49 @@ +import {NavigationContext} from '@react-navigation/core'; +import {NavigationProp} from '@react-navigation/native'; +import {ParamListBase} from '@react-navigation/routers'; +import React, {ComponentType, ForwardedRef, forwardRef, ReactElement, RefAttributes, useContext, useMemo} from 'react'; + +type AddListenerCallback = () => void; + +type RemoveListenerCallback = () => void; + +type NavigationContextValue = { + isFocused: () => boolean; + addListener: () => AddListenerCallback; + removeListener: () => RemoveListenerCallback; +}; + +export default function (WrappedComponent: ComponentType>): (props: TProps & RefAttributes) => ReactElement | null { + function WithNavigationFallback(props: TProps, ref: ForwardedRef) { + const context = useContext(NavigationContext); + + const navigationContextValue: NavigationContextValue = useMemo( + () => ({ + isFocused: () => true, + addListener: () => () => {}, + removeListener: () => () => {}, + }), + [], + ); + + return context ? ( + + ) : ( + }> + + + ); + } + + WithNavigationFallback.displayName = 'WithNavigationFocusWithFallback'; + + return forwardRef(WithNavigationFallback); +} diff --git a/src/components/withToggleVisibilityView.js b/src/components/withToggleVisibilityView.js deleted file mode 100644 index 04c6ab8e8481..000000000000 --- a/src/components/withToggleVisibilityView.js +++ /dev/null @@ -1,52 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import {View} from 'react-native'; -import getComponentDisplayName from '@libs/getComponentDisplayName'; -import styles from '@styles/styles'; -import refPropTypes from './refPropTypes'; - -const toggleVisibilityViewPropTypes = { - /** Whether the content is visible. */ - isVisible: PropTypes.bool, -}; - -export default function (WrappedComponent) { - function WithToggleVisibilityView(props) { - return ( - - - - ); - } - - WithToggleVisibilityView.displayName = `WithToggleVisibilityView(${getComponentDisplayName(WrappedComponent)})`; - WithToggleVisibilityView.propTypes = { - forwardedRef: refPropTypes, - - /** Whether the content is visible. */ - isVisible: PropTypes.bool, - }; - WithToggleVisibilityView.defaultProps = { - forwardedRef: undefined, - isVisible: false, - }; - - const WithToggleVisibilityViewWithRef = React.forwardRef((props, ref) => ( - - )); - - WithToggleVisibilityViewWithRef.displayName = `WithToggleVisibilityViewWithRef`; - - return WithToggleVisibilityViewWithRef; -} - -export {toggleVisibilityViewPropTypes}; diff --git a/src/components/withToggleVisibilityView.tsx b/src/components/withToggleVisibilityView.tsx new file mode 100644 index 000000000000..5e0204f6e06f --- /dev/null +++ b/src/components/withToggleVisibilityView.tsx @@ -0,0 +1,30 @@ +import React, {ComponentType, ForwardedRef, ReactElement, RefAttributes} from 'react'; +import {View} from 'react-native'; +import {SetOptional} from 'type-fest'; +import getComponentDisplayName from '@libs/getComponentDisplayName'; +import styles from '@styles/styles'; + +type ToggleVisibilityViewProps = { + /** Whether the content is visible. */ + isVisible: boolean; +}; + +export default function withToggleVisibilityView( + WrappedComponent: ComponentType>, +): (props: TProps & RefAttributes) => ReactElement | null { + function WithToggleVisibilityView({isVisible = false, ...rest}: SetOptional, ref: ForwardedRef) { + return ( + + + + ); + } + + WithToggleVisibilityView.displayName = `WithToggleVisibilityViewWithRef(${getComponentDisplayName(WrappedComponent)})`; + return React.forwardRef(WithToggleVisibilityView); +} diff --git a/src/hooks/useDebounce.js b/src/hooks/useDebounce.js index 874f9d72b276..62a919925a53 100644 --- a/src/hooks/useDebounce.js +++ b/src/hooks/useDebounce.js @@ -1,5 +1,5 @@ import lodashDebounce from 'lodash/debounce'; -import {useEffect, useRef} from 'react'; +import {useCallback, useEffect, useRef} from 'react'; /** * Create and return a debounced function. @@ -27,11 +27,13 @@ export default function useDebounce(func, wait, options) { return debouncedFn.cancel; }, [func, wait, leading, maxWait, trailing]); - return (...args) => { + const debounceCallback = useCallback((...args) => { const debouncedFn = debouncedFnRef.current; if (debouncedFn) { debouncedFn(...args); } - }; + }, []); + + return debounceCallback; } diff --git a/src/libs/Clipboard/index.native.js b/src/libs/Clipboard/index.native.js deleted file mode 100644 index fe79e38585c4..000000000000 --- a/src/libs/Clipboard/index.native.js +++ /dev/null @@ -1,18 +0,0 @@ -import Clipboard from '@react-native-clipboard/clipboard'; - -/** - * Sets a string on the Clipboard object via @react-native-clipboard/clipboard - * - * @param {String} text - */ -const setString = (text) => { - Clipboard.setString(text); -}; - -export default { - setString, - - // We don't want to set HTML on native platforms so noop them. - canSetHtml: () => false, - setHtml: () => {}, -}; diff --git a/src/libs/Clipboard/index.native.ts b/src/libs/Clipboard/index.native.ts new file mode 100644 index 000000000000..f78c5e4ab230 --- /dev/null +++ b/src/libs/Clipboard/index.native.ts @@ -0,0 +1,19 @@ +import Clipboard from '@react-native-clipboard/clipboard'; +import {CanSetHtml, SetHtml, SetString} from './types'; + +/** + * Sets a string on the Clipboard object via @react-native-clipboard/clipboard + */ +const setString: SetString = (text) => { + Clipboard.setString(text); +}; + +// We don't want to set HTML on native platforms so noop them. +const canSetHtml: CanSetHtml = () => false; +const setHtml: SetHtml = () => {}; + +export default { + setString, + canSetHtml, + setHtml, +}; diff --git a/src/libs/Clipboard/index.js b/src/libs/Clipboard/index.ts similarity index 68% rename from src/libs/Clipboard/index.js rename to src/libs/Clipboard/index.ts index 3fb2091c5cb1..b703b0b4d7f5 100644 --- a/src/libs/Clipboard/index.js +++ b/src/libs/Clipboard/index.ts @@ -1,16 +1,34 @@ import Clipboard from '@react-native-clipboard/clipboard'; -import lodashGet from 'lodash/get'; import * as Browser from '@libs/Browser'; import CONST from '@src/CONST'; +import {CanSetHtml, SetHtml, SetString} from './types'; -const canSetHtml = () => lodashGet(navigator, 'clipboard.write'); +type ComposerSelection = { + start: number; + end: number; + direction: 'forward' | 'backward' | 'none'; +}; + +type AnchorSelection = { + anchorOffset: number; + focusOffset: number; + anchorNode: Node; + focusNode: Node; +}; + +type NullableObject = {[K in keyof T]: T[K] | null}; + +type OriginalSelection = ComposerSelection | NullableObject; + +const canSetHtml: CanSetHtml = + () => + (...args: ClipboardItems) => + navigator?.clipboard?.write([...args]); /** * Deprecated method to write the content as HTML to clipboard. - * @param {String} html HTML representation - * @param {String} text Plain text representation */ -function setHTMLSync(html, text) { +function setHTMLSync(html: string, text: string) { const node = document.createElement('span'); node.textContent = html; node.style.all = 'unset'; @@ -21,16 +39,21 @@ function setHTMLSync(html, text) { node.addEventListener('copy', (e) => { e.stopPropagation(); e.preventDefault(); - e.clipboardData.clearData(); - e.clipboardData.setData('text/html', html); - e.clipboardData.setData('text/plain', text); + e.clipboardData?.clearData(); + e.clipboardData?.setData('text/html', html); + e.clipboardData?.setData('text/plain', text); }); document.body.appendChild(node); - const selection = window.getSelection(); - const firstAnchorChild = selection.anchorNode && selection.anchorNode.firstChild; + const selection = window?.getSelection(); + + if (selection === null) { + return; + } + + const firstAnchorChild = selection.anchorNode?.firstChild; const isComposer = firstAnchorChild instanceof HTMLTextAreaElement; - let originalSelection = null; + let originalSelection: OriginalSelection | null = null; if (isComposer) { originalSelection = { start: firstAnchorChild.selectionStart, @@ -60,12 +83,14 @@ function setHTMLSync(html, text) { selection.removeAllRanges(); - if (isComposer) { + const anchorSelection = originalSelection as AnchorSelection; + + if (isComposer && 'start' in originalSelection) { firstAnchorChild.setSelectionRange(originalSelection.start, originalSelection.end, originalSelection.direction); - } else if (originalSelection.anchorNode && originalSelection.focusNode) { + } else if (anchorSelection.anchorNode && anchorSelection.focusNode) { // When copying to the clipboard here, the original values of anchorNode and focusNode will be null since there will be no user selection. // We are adding a check to prevent null values from being passed to setBaseAndExtent, in accordance with the standards of the Selection API as outlined here: https://w3c.github.io/selection-api/#dom-selection-setbaseandextent. - selection.setBaseAndExtent(originalSelection.anchorNode, originalSelection.anchorOffset, originalSelection.focusNode, originalSelection.focusOffset); + selection.setBaseAndExtent(anchorSelection.anchorNode, anchorSelection.anchorOffset, anchorSelection.focusNode, anchorSelection.focusOffset); } document.body.removeChild(node); @@ -73,10 +98,8 @@ function setHTMLSync(html, text) { /** * Writes the content as HTML if the web client supports it. - * @param {String} html HTML representation - * @param {String} text Plain text representation */ -const setHtml = (html, text) => { +const setHtml: SetHtml = (html: string, text: string) => { if (!html || !text) { return; } @@ -93,8 +116,8 @@ const setHtml = (html, text) => { setHTMLSync(html, text); } else { navigator.clipboard.write([ - // eslint-disable-next-line no-undef new ClipboardItem({ + /* eslint-disable @typescript-eslint/naming-convention */ 'text/html': new Blob([html], {type: 'text/html'}), 'text/plain': new Blob([text], {type: 'text/plain'}), }), @@ -104,10 +127,8 @@ const setHtml = (html, text) => { /** * Sets a string on the Clipboard object via react-native-web - * - * @param {String} text */ -const setString = (text) => { +const setString: SetString = (text) => { Clipboard.setString(text); }; diff --git a/src/libs/Clipboard/types.ts b/src/libs/Clipboard/types.ts new file mode 100644 index 000000000000..1d899144a2ba --- /dev/null +++ b/src/libs/Clipboard/types.ts @@ -0,0 +1,5 @@ +type SetString = (text: string) => void; +type SetHtml = (html: string, text: string) => void; +type CanSetHtml = (() => (...args: ClipboardItems) => Promise) | (() => boolean); + +export type {SetString, CanSetHtml, SetHtml}; diff --git a/src/libs/ComposerUtils/index.ts b/src/libs/ComposerUtils/index.ts index 5a7da7ca08cf..58e1efa7aa65 100644 --- a/src/libs/ComposerUtils/index.ts +++ b/src/libs/ComposerUtils/index.ts @@ -32,7 +32,11 @@ function canSkipTriggerHotkeys(isSmallScreenWidth: boolean, isKeyboardShown: boo */ function getCommonSuffixLength(str1: string, str2: string): number { let i = 0; - while (str1[str1.length - 1 - i] === str2[str2.length - 1 - i]) { + if (str1.length === 0 || str2.length === 0) { + return 0; + } + const minLen = Math.min(str1.length, str2.length); + while (i < minLen && str1[str1.length - 1 - i] === str2[str2.length - 1 - i]) { i++; } return i; 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/OptionsListUtils.js b/src/libs/OptionsListUtils.js index 99853975f86a..54d09b75eff2 100644 --- a/src/libs/OptionsListUtils.js +++ b/src/libs/OptionsListUtils.js @@ -87,7 +87,6 @@ Onyx.connect({ const policyExpenseReports = {}; Onyx.connect({ key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, callback: (report, key) => { if (!ReportUtils.isPolicyExpenseChat(report)) { return; diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 45bdfb18b451..11e11f549682 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -406,12 +406,9 @@ function getLastVisibleMessage(reportID: string, actionsToMerge: ReportActions = }; } - let messageText = message?.text ?? ''; - if (messageText) { - messageText = String(messageText).replace(CONST.REGEX.AFTER_FIRST_LINE_BREAK, '').substring(0, CONST.REPORT.LAST_MESSAGE_TEXT_MAX_LENGTH).trim(); - } + const messageText = message?.text ?? ''; return { - lastMessageText: messageText, + lastMessageText: String(messageText).replace(CONST.REGEX.AFTER_FIRST_LINE_BREAK, '').substring(0, CONST.REPORT.LAST_MESSAGE_TEXT_MAX_LENGTH).trim(), }; } diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js index 5bb8fd4ad4fc..1e3fc5297193 100644 --- a/src/libs/ReportUtils.js +++ b/src/libs/ReportUtils.js @@ -818,15 +818,8 @@ function isOneOnOneChat(report) { * @returns {Object} */ function getReport(reportID) { - /** - * Using typical string concatenation here due to performance issues - * with template literals. - */ - if (!allReports) { - return {}; - } - - return allReports[ONYXKEYS.COLLECTION.REPORT + reportID] || {}; + // Deleted reports are set to null and lodashGet will still return null in that case, so we need to add an extra check + return lodashGet(allReports, `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {}) || {}; } /** @@ -1499,15 +1492,18 @@ function getMoneyRequestSpendBreakdown(report, allReportsDict = null) { } if (moneyRequestReport) { let nonReimbursableSpend = lodashGet(moneyRequestReport, 'nonReimbursableTotal', 0); - let reimbursableSpend = lodashGet(moneyRequestReport, 'total', 0); + let totalSpend = lodashGet(moneyRequestReport, 'total', 0); - if (nonReimbursableSpend + reimbursableSpend !== 0) { + if (nonReimbursableSpend + totalSpend !== 0) { // There is a possibility that if the Expense report has a negative total. // This is because there are instances where you can get a credit back on your card, // or you enter a negative expense to “offset” future expenses nonReimbursableSpend = isExpenseReport(moneyRequestReport) ? nonReimbursableSpend * -1 : Math.abs(nonReimbursableSpend); - reimbursableSpend = isExpenseReport(moneyRequestReport) ? reimbursableSpend * -1 : Math.abs(reimbursableSpend); - const totalDisplaySpend = nonReimbursableSpend + reimbursableSpend; + totalSpend = isExpenseReport(moneyRequestReport) ? totalSpend * -1 : Math.abs(totalSpend); + + const totalDisplaySpend = totalSpend; + const reimbursableSpend = totalDisplaySpend - nonReimbursableSpend; + return { nonReimbursableSpend, reimbursableSpend, @@ -1530,25 +1526,14 @@ function getMoneyRequestSpendBreakdown(report, allReportsDict = null) { * @returns {String} */ function getPolicyExpenseChatName(report, policy = undefined) { - const ownerAccountID = report.ownerAccountID; - const personalDetails = allPersonalDetails[ownerAccountID]; - const login = personalDetails ? personalDetails.login : null; - const reportOwnerDisplayName = getDisplayNameForParticipant(report.ownerAccountID) || login || report.reportName; + const reportOwnerDisplayName = getDisplayNameForParticipant(report.ownerAccountID) || lodashGet(allPersonalDetails, [report.ownerAccountID, 'login']) || report.reportName; // If the policy expense chat is owned by this user, use the name of the policy as the report name. if (report.isOwnPolicyExpenseChat) { return getPolicyName(report, false, policy); } - let policyExpenseChatRole = 'user'; - /** - * Using typical string concatenation here due to performance issues - * with template literals. - */ - const policyItem = allPolicies[ONYXKEYS.COLLECTION.POLICY + report.policyID]; - if (policyItem) { - policyExpenseChatRole = policyItem.role || 'user'; - } + const policyExpenseChatRole = lodashGet(allPolicies, [`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`, 'role']) || 'user'; // If this user is not admin and this policy expense chat has been archived because of account merging, this must be an old workspace chat // of the account which was merged into the current user's account. Use the name of the policy as the name of the report. diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index 4951432bcd03..80ed96d25d65 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -38,6 +38,7 @@ Onyx.connect({ const reportActionsForDisplay = actionsArray.filter( (reportAction, actionKey) => ReportActionsUtils.shouldReportActionBeVisible(reportAction, actionKey) && + !ReportActionsUtils.isWhisperAction(reportAction) && reportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.CREATED && reportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, ); @@ -156,6 +157,18 @@ function getOrderedReportIDs( } } + // There are a few properties that need to be calculated for the report which are used when sorting reports. + reportsToDisplay.forEach((report) => { + // Normally, the spread operator would be used here to clone the report and prevent the need to reassign the params. + // However, this code needs to be very performant to handle thousands of reports, so in the interest of speed, we're just going to disable this lint rule and add + // the reportDisplayName property to the report object directly. + // eslint-disable-next-line no-param-reassign + report.displayName = ReportUtils.getReportName(report); + + // eslint-disable-next-line no-param-reassign + report.iouReportAmount = ReportUtils.getMoneyRequestReimbursableTotal(report, allReports); + }); + // The LHN is split into four distinct groups, and each group is sorted a little differently. The groups will ALWAYS be in this order: // 1. Pinned/GBR - Always sorted by reportDisplayName // 2. Drafts - Always sorted by reportDisplayName @@ -169,17 +182,7 @@ function getOrderedReportIDs( const draftReports: Report[] = []; const nonArchivedReports: Report[] = []; const archivedReports: Report[] = []; - reportsToDisplay.forEach((report) => { - // Normally, the spread operator would be used here to clone the report and prevent the need to reassign the params. - // However, this code needs to be very performant to handle thousands of reports, so in the interest of speed, we're just going to disable this lint rule and add - // the reportDisplayName property to the report object directly. - // eslint-disable-next-line no-param-reassign - report.displayName = ReportUtils.getReportName(report); - - // eslint-disable-next-line no-param-reassign - report.iouReportAmount = ReportUtils.getMoneyRequestReimbursableTotal(report, allReports); - const isPinned = report.isPinned ?? false; if (isPinned || ReportUtils.requiresAttentionFromCurrentUser(report)) { pinnedAndGBRReports.push(report); @@ -450,7 +453,7 @@ function getOptionData( } else if (lastAction?.actionName !== CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW && lastActorDisplayName && lastMessageTextFromReport) { result.alternateText = `${lastActorDisplayName}: ${lastMessageText}`; } else { - result.alternateText = lastAction && lastMessageTextFromReport.length > 0 ? lastMessageText : Localize.translate(preferredLocale, 'report.noActivityYet'); + result.alternateText = lastMessageTextFromReport.length > 0 ? lastMessageText : Localize.translate(preferredLocale, 'report.noActivityYet'); } } else { if (!lastMessageText) { diff --git a/src/libs/UnreadIndicatorUpdater/index.js b/src/libs/UnreadIndicatorUpdater/index.js index bfa0cd911177..9af74f8313c3 100644 --- a/src/libs/UnreadIndicatorUpdater/index.js +++ b/src/libs/UnreadIndicatorUpdater/index.js @@ -1,4 +1,3 @@ -import {InteractionManager} from 'react-native'; import Onyx from 'react-native-onyx'; import _ from 'underscore'; import * as ReportUtils from '@libs/ReportUtils'; @@ -6,33 +5,11 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import updateUnread from './updateUnread/index'; -let previousUnreadCount = 0; - Onyx.connect({ key: ONYXKEYS.COLLECTION.REPORT, waitForCollectionCallback: true, callback: (reportsFromOnyx) => { - if (!reportsFromOnyx) { - return; - } - - /** - * We need to wait until after interactions have finished to update the unread count because otherwise - * the unread count will be updated while the interactions/animations are in progress and we don't want - * to put more work on the main thread. - * - * For eg. On web we are manipulating DOM and it makes it a better candidate to wait until after interactions - * have finished. - * - * More info: https://reactnative.dev/docs/interactionmanager - */ - InteractionManager.runAfterInteractions(() => { - const unreadReports = _.filter(reportsFromOnyx, (report) => ReportUtils.isUnread(report) && report.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN); - const unreadReportsCount = _.size(unreadReports); - if (previousUnreadCount !== unreadReportsCount) { - previousUnreadCount = unreadReportsCount; - updateUnread(unreadReportsCount); - } - }); + const unreadReports = _.filter(reportsFromOnyx, (report) => ReportUtils.isUnread(report) && report.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN); + updateUnread(_.size(unreadReports)); }, }); diff --git a/src/libs/actions/Policy.js b/src/libs/actions/Policy.js index e716c17de8b2..9b33ff9b086e 100644 --- a/src/libs/actions/Policy.js +++ b/src/libs/actions/Policy.js @@ -1,6 +1,7 @@ import {PUBLIC_DOMAINS} from 'expensify-common/lib/CONST'; import Str from 'expensify-common/lib/str'; import {escapeRegExp} from 'lodash'; +import filter from 'lodash/filter'; import lodashGet from 'lodash/get'; import lodashUnion from 'lodash/union'; import Onyx from 'react-native-onyx'; @@ -74,6 +75,12 @@ Onyx.connect({ callback: (val) => (allPersonalDetails = val), }); +let reimbursementAccount; +Onyx.connect({ + key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, + callback: (val) => (reimbursementAccount = val), +}); + let allRecentlyUsedCategories = {}; Onyx.connect({ key: ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES, @@ -96,6 +103,36 @@ function updateLastAccessedWorkspace(policyID) { Onyx.set(ONYXKEYS.LAST_ACCESSED_WORKSPACE_POLICY_ID, policyID); } +/** + * Check if the user has any active free policies (aka workspaces) + * + * @param {Array} policies + * @returns {Boolean} + */ +function hasActiveFreePolicy(policies) { + const adminFreePolicies = _.filter(policies, (policy) => policy && policy.type === CONST.POLICY.TYPE.FREE && policy.role === CONST.POLICY.ROLE.ADMIN); + + if (adminFreePolicies.length === 0) { + return false; + } + + if (_.some(adminFreePolicies, (policy) => !policy.pendingAction)) { + return true; + } + + if (_.some(adminFreePolicies, (policy) => policy.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD)) { + return true; + } + + if (_.some(adminFreePolicies, (policy) => policy.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE)) { + return false; + } + + // If there are no add or delete pending actions the only option left is an update + // pendingAction, in which case we should return true. + return true; +} + /** * Delete the workspace * @@ -104,6 +141,7 @@ function updateLastAccessedWorkspace(policyID) { * @param {String} policyName */ function deleteWorkspace(policyID, reports, policyName) { + const filteredPolicies = filter(allPolicies, (policy) => policy.id !== policyID); const optimisticData = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -146,6 +184,18 @@ function deleteWorkspace(policyID, reports, policyName) { value: optimisticReportActions, }; }), + + ...(!hasActiveFreePolicy(filteredPolicies) + ? [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, + value: { + errors: null, + }, + }, + ] + : []), ]; // Restore the old report stateNum and statusNum @@ -160,6 +210,13 @@ function deleteWorkspace(policyID, reports, policyName) { oldPolicyName, }, })), + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, + value: { + errors: lodashGet(reimbursementAccount, 'errors', null), + }, + }, ]; // We don't need success data since the push notification will update @@ -183,36 +240,6 @@ function isAdminOfFreePolicy(policies) { return _.some(policies, (policy) => policy && policy.type === CONST.POLICY.TYPE.FREE && policy.role === CONST.POLICY.ROLE.ADMIN); } -/** - * Check if the user has any active free policies (aka workspaces) - * - * @param {Array} policies - * @returns {Boolean} - */ -function hasActiveFreePolicy(policies) { - const adminFreePolicies = _.filter(policies, (policy) => policy && policy.type === CONST.POLICY.TYPE.FREE && policy.role === CONST.POLICY.ROLE.ADMIN); - - if (adminFreePolicies.length === 0) { - return false; - } - - if (_.some(adminFreePolicies, (policy) => !policy.pendingAction)) { - return true; - } - - if (_.some(adminFreePolicies, (policy) => policy.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD)) { - return true; - } - - if (_.some(adminFreePolicies, (policy) => policy.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE)) { - return false; - } - - // If there are no add or delete pending actions the only option left is an update - // pendingAction, in which case we should return true. - return true; -} - /** * Remove the passed members from the policy employeeList * diff --git a/src/libs/actions/Report.js b/src/libs/actions/Report.js index 4646e0e33da1..1de15c1184cb 100644 --- a/src/libs/actions/Report.js +++ b/src/libs/actions/Report.js @@ -65,7 +65,6 @@ Onyx.connect({ const currentReportData = {}; Onyx.connect({ key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, callback: (data, key) => { if (!key || !data) { return; 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/ReimbursementAccount/AddressForm.js b/src/pages/ReimbursementAccount/AddressForm.js index 36fbc6e4c59d..b8675fd9cc0e 100644 --- a/src/pages/ReimbursementAccount/AddressForm.js +++ b/src/pages/ReimbursementAccount/AddressForm.js @@ -107,6 +107,7 @@ function AddressForm(props) { hint={props.translate('common.noPO')} renamedInputKeys={props.inputKeys} maxInputLength={CONST.FORM_CHARACTER_LIMIT} + isLimitedToUSA /> 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 89% rename from src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js rename to src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.js index 3bbc2b03ff6f..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(); @@ -117,6 +119,7 @@ function ComposerWithSuggestions({ return draft; }); const commentRef = useRef(value); + const lastTextRef = useRef(value); const {isSmallScreenWidth} = useWindowDimensions(); const maxComposerLines = isSmallScreenWidth ? CONST.COMPOSER.MAX_LINES_SMALL_SCREEN : CONST.COMPOSER.MAX_LINES; @@ -196,6 +199,50 @@ function ComposerWithSuggestions({ RNTextInputReset.resetKeyboardInput(findNodeHandle(textInputRef.current)); }, [textInputRef]); + /** + * Find the newly added characters between the previous text and the new text based on the selection. + * + * @param {string} prevText - The previous text. + * @param {string} newText - The new text. + * @returns {object} An object containing information about the newly added characters. + * @property {number} startIndex - The start index of the newly added characters in the new text. + * @property {number} endIndex - The end index of the newly added characters in the new text. + * @property {string} diff - The newly added characters. + */ + const findNewlyAddedChars = useCallback( + (prevText, newText) => { + let startIndex = -1; + let endIndex = -1; + let currentIndex = 0; + + // Find the first character mismatch with newText + while (currentIndex < newText.length && prevText.charAt(currentIndex) === newText.charAt(currentIndex) && selection.start > currentIndex) { + currentIndex++; + } + + if (currentIndex < newText.length) { + startIndex = currentIndex; + + // if text is getting pasted over find length of common suffix and subtract it from new text length + if (selection.end - selection.start > 0) { + const commonSuffixLength = ComposerUtils.getCommonSuffixLength(prevText, newText); + endIndex = newText.length - commonSuffixLength; + } else { + endIndex = currentIndex + (newText.length - prevText.length); + } + } + + return { + startIndex, + endIndex, + diff: newText.substring(startIndex, endIndex), + }; + }, + [selection.end, selection.start], + ); + + const insertWhiteSpace = (text, index) => `${text.slice(0, index)} ${text.slice(index)}`; + const debouncedSaveReportComment = useMemo( () => _.debounce((selectedReportID, newComment) => { @@ -213,7 +260,14 @@ function ComposerWithSuggestions({ const updateComment = useCallback( (commentValue, shouldDebounceSaveComment) => { raiseIsScrollLikelyLayoutTriggered(); - const {text: newComment, emojis} = EmojiUtils.replaceAndExtractEmojis(commentValue, preferredSkinTone, preferredLocale); + const {startIndex, endIndex, diff} = findNewlyAddedChars(lastTextRef.current, commentValue); + const isEmojiInserted = diff.length && endIndex > startIndex && EmojiUtils.containsOnlyEmojis(diff); + const {text: newComment, emojis} = EmojiUtils.replaceAndExtractEmojis( + isEmojiInserted ? insertWhiteSpace(commentValue, endIndex) : commentValue, + preferredSkinTone, + preferredLocale, + ); + if (!_.isEmpty(emojis)) { const newEmojis = EmojiUtils.getAddedEmojis(emojis, emojisPresentBefore.current); if (!_.isEmpty(newEmojis)) { @@ -226,14 +280,8 @@ function ComposerWithSuggestions({ } } const newCommentConverted = convertToLTRForComposer(newComment); - const isNewCommentEmpty = !!newCommentConverted.match(/^(\s)*$/); - const isPrevCommentEmpty = !!commentRef.current.match(/^(\s)*$/); - - /** Only update isCommentEmpty state if it's different from previous one */ - if (isNewCommentEmpty !== isPrevCommentEmpty) { - setIsCommentEmpty(isNewCommentEmpty); - } emojisPresentBefore.current = emojis; + setIsCommentEmpty(!!newCommentConverted.match(/^(\s)*$/)); setValue(newCommentConverted); if (commentValue !== newComment) { const remainder = ComposerUtils.getCommonSuffixLength(commentValue, newComment); @@ -264,13 +312,14 @@ function ComposerWithSuggestions({ } }, [ - debouncedUpdateFrequentlyUsedEmojis, - preferredLocale, + raiseIsScrollLikelyLayoutTriggered, + findNewlyAddedChars, preferredSkinTone, - reportID, + preferredLocale, setIsCommentEmpty, + debouncedUpdateFrequentlyUsedEmojis, suggestionsRef, - raiseIsScrollLikelyLayoutTriggered, + reportID, debouncedSaveReportComment, ], ); @@ -321,14 +370,8 @@ function ComposerWithSuggestions({ * @param {Boolean} shouldAddTrailSpace */ const replaceSelectionWithText = useCallback( - (text, shouldAddTrailSpace = true) => { - const updatedText = shouldAddTrailSpace ? `${text} ` : text; - const selectionSpaceLength = shouldAddTrailSpace ? CONST.SPACE_LENGTH : 0; - updateComment(ComposerUtils.insertText(commentRef.current, selection, updatedText)); - setSelection((prevSelection) => ({ - start: prevSelection.start + text.length + selectionSpaceLength, - end: prevSelection.start + text.length + selectionSpaceLength, - })); + (text) => { + updateComment(ComposerUtils.insertText(commentRef.current, selection, text)); }, [selection, updateComment], ); @@ -452,7 +495,12 @@ function ComposerWithSuggestions({ } focus(); - replaceSelectionWithText(e.key, false); + // Reset cursor to last known location + setSelection((prevSelection) => ({ + start: prevSelection.start + 1, + end: prevSelection.end + 1, + })); + replaceSelectionWithText(e.key); }, [checkComposerVisibility, focus, replaceSelectionWithText], ); @@ -510,10 +558,16 @@ function ComposerWithSuggestions({ if (value.length === 0) { return; } + Report.setReportWithDraft(reportID, true); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + useEffect(() => { + lastTextRef.current = value; + }, [value]); + useImperativeHandle( forwardedRef, () => ({ @@ -593,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/ReportActionCompose/SuggestionMention.js b/src/pages/home/report/ReportActionCompose/SuggestionMention.js index baf93da6ccc4..2ea2dd334528 100644 --- a/src/pages/home/report/ReportActionCompose/SuggestionMention.js +++ b/src/pages/home/report/ReportActionCompose/SuggestionMention.js @@ -1,17 +1,15 @@ import PropTypes from 'prop-types'; import React, {useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react'; -import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; import * as Expensicons from '@components/Icon/Expensicons'; import MentionSuggestions from '@components/MentionSuggestions'; +import {usePersonalDetails} from '@components/OnyxProvider'; import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; import useLocalize from '@hooks/useLocalize'; import usePrevious from '@hooks/usePrevious'; import * as SuggestionsUtils from '@libs/SuggestionUtils'; import * as UserUtils from '@libs/UserUtils'; -import personalDetailsPropType from '@pages/personalDetailsPropType'; import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; import * as SuggestionProps from './suggestionProps'; /** @@ -29,9 +27,6 @@ const defaultSuggestionsValues = { }; const propTypes = { - /** Personal details of all users */ - personalDetails: PropTypes.objectOf(personalDetailsPropType), - /** A ref to this component */ forwardedRef: PropTypes.shape({current: PropTypes.shape({})}), @@ -39,7 +34,6 @@ const propTypes = { }; const defaultProps = { - personalDetails: {}, forwardedRef: null, }; @@ -49,7 +43,6 @@ function SuggestionMention({ selection, setSelection, isComposerFullSize, - personalDetails, updateComment, composerHeight, forwardedRef, @@ -57,6 +50,7 @@ function SuggestionMention({ measureParentContainer, isComposerFocused, }) { + const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT; const {translate} = useLocalize(); const previousValue = usePrevious(value); const [suggestionValues, setSuggestionValues] = useState(defaultSuggestionsValues); @@ -316,8 +310,4 @@ const SuggestionMentionWithRef = React.forwardRef((props, ref) => ( SuggestionMentionWithRef.displayName = 'SuggestionMentionWithRef'; -export default withOnyx({ - personalDetails: { - key: ONYXKEYS.PERSONAL_DETAILS_LIST, - }, -})(SuggestionMentionWithRef); +export default SuggestionMentionWithRef; 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 b3efb0388364..67c3d5f5c5ec 100644 --- a/src/pages/home/report/ReportActionItemMessageEdit.js +++ b/src/pages/home/report/ReportActionItemMessageEdit.js @@ -316,7 +316,7 @@ function ReportActionItemMessageEdit(props) { // When user tries to save the empty message, it will delete it. Prompt the user to confirm deleting. if (!trimmedNewDraft) { textInputRef.current.blur(); - ReportActionContextMenu.showDeleteModal(props.reportID, props.action, false, deleteDraft, () => InteractionManager.runAfterInteractions(() => textInputRef.current.focus())); + ReportActionContextMenu.showDeleteModal(props.reportID, props.action, true, deleteDraft, () => InteractionManager.runAfterInteractions(() => textInputRef.current.focus())); return; } Report.editReportComment(props.reportID, props.action, trimmedNewDraft); diff --git a/src/pages/home/report/ReportActionItemSingle.js b/src/pages/home/report/ReportActionItemSingle.js index 2b4526af98d1..e9e1ef39e417 100644 --- a/src/pages/home/report/ReportActionItemSingle.js +++ b/src/pages/home/report/ReportActionItemSingle.js @@ -108,7 +108,7 @@ function ReportActionItemSingle(props) { // If this is a report preview, display names and avatars of both people involved let secondaryAvatar = {}; - const primaryDisplayName = ReportUtils.getDisplayNameForParticipant(actorAccountID); + const primaryDisplayName = displayName; if (displayAllActors) { // The ownerAccountID and actorAccountID can be the same if the a user requests money back from the IOU's original creator, in that case we need to use managerID to avoid displaying the same user twice const secondaryAccountId = props.iouReport.ownerAccountID === actorAccountID ? props.iouReport.managerID : props.iouReport.ownerAccountID; 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/sidebar/SidebarLinksData.js b/src/pages/home/sidebar/SidebarLinksData.js index 1e5e11fd9fcb..293dc3f5cd9d 100644 --- a/src/pages/home/sidebar/SidebarLinksData.js +++ b/src/pages/home/sidebar/SidebarLinksData.js @@ -198,28 +198,23 @@ export default compose( chatReports: { key: ONYXKEYS.COLLECTION.REPORT, selector: chatReportSelector, - initialValue: {}, }, isLoadingReportData: { key: ONYXKEYS.IS_LOADING_REPORT_DATA, }, priorityMode: { key: ONYXKEYS.NVP_PRIORITY_MODE, - initialValue: CONST.PRIORITY_MODE.DEFAULT, }, betas: { key: ONYXKEYS.BETAS, - initialValue: [], }, allReportActions: { key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, selector: reportActionsSelector, - initialValue: {}, }, policies: { key: ONYXKEYS.COLLECTION.POLICY, selector: policySelector, - initialValue: {}, }, }), )(SidebarLinksData); diff --git a/src/pages/iou/IOUCurrencySelection.js b/src/pages/iou/IOUCurrencySelection.js index c7b5885865df..20344a08a2c8 100644 --- a/src/pages/iou/IOUCurrencySelection.js +++ b/src/pages/iou/IOUCurrencySelection.js @@ -126,8 +126,12 @@ function IOUCurrencySelection(props) { }; }); - const searchRegex = new RegExp(Str.escapeForRegExp(searchValue.trim()), 'i'); - const filteredCurrencies = _.filter(currencyOptions, (currencyOption) => searchRegex.test(currencyOption.text) || searchRegex.test(currencyOption.currencyName)); + const searchRegex = new RegExp(Str.escapeForRegExp(searchValue.trim().replace(CONST.REGEX.ANY_SPACE, ' ')), 'i'); + const filteredCurrencies = _.filter( + currencyOptions, + (currencyOption) => + searchRegex.test(currencyOption.text.replace(CONST.REGEX.ANY_SPACE, ' ')) || searchRegex.test(currencyOption.currencyName.replace(CONST.REGEX.ANY_SPACE, ' ')), + ); const isEmpty = searchValue.trim() && !filteredCurrencies.length; return { diff --git a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js index ec5ab3a678bd..8302564cfcb7 100644 --- a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js +++ b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js @@ -1,15 +1,19 @@ import lodashGet from 'lodash/get'; +import lodashSize from 'lodash/size'; import PropTypes from 'prop-types'; import React, {useCallback, useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; +import transactionPropTypes from '@components/transactionPropTypes'; import useInitialValue from '@hooks/useInitialValue'; import useLocalize from '@hooks/useLocalize'; +import compose from '@libs/compose'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import * as MoneyRequestUtils from '@libs/MoneyRequestUtils'; import Navigation from '@libs/Navigation/Navigation'; +import * as TransactionUtils from '@libs/TransactionUtils'; import {iouDefaultProps, iouPropTypes} from '@pages/iou/propTypes'; import styles from '@styles/styles'; import * as IOU from '@userActions/IOU'; @@ -36,14 +40,18 @@ const propTypes = { /** The current tab we have navigated to in the request modal. String that corresponds to the request type. */ selectedTab: PropTypes.oneOf([CONST.TAB.DISTANCE, CONST.TAB.MANUAL, CONST.TAB.SCAN]), + + /** Transaction that stores the distance request data */ + transaction: transactionPropTypes, }; const defaultProps = { iou: iouDefaultProps, + transaction: {}, selectedTab: undefined, }; -function MoneyRequestParticipantsPage({iou, selectedTab, route}) { +function MoneyRequestParticipantsPage({iou, selectedTab, route, transaction}) { const {translate} = useLocalize(); const prevMoneyRequestId = useRef(iou.id); const optionsSelectorRef = useRef(); @@ -54,7 +62,9 @@ function MoneyRequestParticipantsPage({iou, selectedTab, route}) { const isScanRequest = MoneyRequestUtils.isScanRequest(selectedTab); const isSplitRequest = iou.id === CONST.IOU.TYPE.SPLIT; const [headerTitle, setHeaderTitle] = useState(); - + const waypoints = lodashGet(transaction, 'comment.waypoints', {}); + const validatedWaypoints = TransactionUtils.getValidWaypoints(waypoints); + const isInvalidWaypoint = lodashSize(validatedWaypoints) < 2; useEffect(() => { if (isDistanceRequest) { setHeaderTitle(translate('common.distance')); @@ -85,10 +95,12 @@ function MoneyRequestParticipantsPage({iou, selectedTab, route}) { }, []); useEffect(() => { + const isInvalidDistanceRequest = !isDistanceRequest || isInvalidWaypoint; + // ID in Onyx could change by initiating a new request in a separate browser tab or completing a request if (prevMoneyRequestId.current !== iou.id) { // The ID is cleared on completing a request. In that case, we will do nothing - if (iou.id && !isDistanceRequest && !isSplitRequest) { + if (iou.id && isInvalidDistanceRequest && !isSplitRequest) { navigateBack(true); } return; @@ -100,14 +112,14 @@ function MoneyRequestParticipantsPage({iou, selectedTab, route}) { if (shouldReset) { IOU.resetMoneyRequestInfo(moneyRequestId); } - if (!isDistanceRequest && ((iou.amount === 0 && !iou.receiptPath) || shouldReset)) { + if (isInvalidDistanceRequest && ((iou.amount === 0 && !iou.receiptPath) || shouldReset)) { navigateBack(true); } return () => { prevMoneyRequestId.current = iou.id; }; - }, [iou.amount, iou.id, iou.receiptPath, isDistanceRequest, isSplitRequest, iouType, reportID, navigateBack]); + }, [iou.amount, iou.id, iou.receiptPath, isDistanceRequest, isSplitRequest, iouType, reportID, navigateBack, isInvalidWaypoint]); return ( `${ONYXKEYS.COLLECTION.TRANSACTION}${iou.transactionID}`, + }, + }), +)(MoneyRequestParticipantsPage); 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/ValidateCodeForm/BaseValidateCodeForm.js b/src/pages/settings/Profile/Contacts/ValidateCodeForm/BaseValidateCodeForm.js index 3cdbb815f66f..26f66e3f0294 100644 --- a/src/pages/settings/Profile/Contacts/ValidateCodeForm/BaseValidateCodeForm.js +++ b/src/pages/settings/Profile/Contacts/ValidateCodeForm/BaseValidateCodeForm.js @@ -228,6 +228,7 @@ function BaseValidateCodeForm(props) { BaseValidateCodeForm.propTypes = propTypes; BaseValidateCodeForm.defaultProps = defaultProps; +BaseValidateCodeForm.displayName = 'BaseValidateCodeForm'; export default compose( withLocalize, diff --git a/src/pages/settings/Profile/PersonalDetails/AddressPage.js b/src/pages/settings/Profile/PersonalDetails/AddressPage.js index a6cb069780b2..e44b00920544 100644 --- a/src/pages/settings/Profile/PersonalDetails/AddressPage.js +++ b/src/pages/settings/Profile/PersonalDetails/AddressPage.js @@ -81,6 +81,7 @@ function AddressPage({privatePersonalDetails, route}) { const [street1, street2] = (address.street || '').split('\n'); const [state, setState] = useState(address.state); const [city, setCity] = useState(address.city); + const [zipcode, setZipcode] = useState(address.zip); useEffect(() => { 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/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/LoginForm/BaseLoginForm.js b/src/pages/signin/LoginForm/BaseLoginForm.js index 9529d7fd0d60..2c96de33557e 100644 --- a/src/pages/signin/LoginForm/BaseLoginForm.js +++ b/src/pages/signin/LoginForm/BaseLoginForm.js @@ -15,7 +15,7 @@ import Text from '@components/Text'; import TextInput from '@components/TextInput'; import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; import withNavigationFocus from '@components/withNavigationFocus'; -import withToggleVisibilityView, {toggleVisibilityViewPropTypes} from '@components/withToggleVisibilityView'; +import withToggleVisibilityView from '@components/withToggleVisibilityView'; import withWindowDimensions, {windowDimensionsPropTypes} from '@components/withWindowDimensions'; import usePrevious from '@hooks/usePrevious'; import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus'; @@ -72,14 +72,14 @@ const propTypes = { /** Whether or not the sign in page is being rendered in the RHP modal */ isInModal: PropTypes.bool, + isVisible: PropTypes.bool.isRequired, + /** Whether navigation is focused */ isFocused: PropTypes.bool.isRequired, ...windowDimensionsPropTypes, ...withLocalizePropTypes, - - ...toggleVisibilityViewPropTypes, }; const defaultProps = { 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/WorkspaceSettingsPage.js b/src/pages/workspace/WorkspaceSettingsPage.js index 9d1000179291..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,11 +99,10 @@ function WorkspaceSettingsPage({policy, currencyList, windowWidth, route}) { guidesCallTaskID={CONST.GUIDES_CALL_TASK_IDS.WORKSPACE_SETTINGS} > {(hasVBA) => ( -
- - + -
+ )} ); diff --git a/src/pages/workspace/WorkspacesListPage.js b/src/pages/workspace/WorkspacesListPage.js index 572dd9d1152f..f4f9e0c818c8 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/styles/StyleUtils.ts b/src/styles/StyleUtils.ts index 85a9bc7110bd..e826142fc022 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/fontFamily/multiFontFamily.ts b/src/styles/fontFamily/multiFontFamily.ts index 2edd17548354..5bd89e0d4bcb 100644 --- a/src/styles/fontFamily/multiFontFamily.ts +++ b/src/styles/fontFamily/multiFontFamily.ts @@ -1,3 +1,5 @@ +import getOperatingSystem from '@libs/getOperatingSystem'; +import CONST from '@src/CONST'; import {multiBold} from './bold'; import FontFamilyStyles from './types'; @@ -16,4 +18,10 @@ const fontFamily: FontFamilyStyles = { MONOSPACE_BOLD_ITALIC: 'ExpensifyMono-Bold, Segoe UI Emoji, Noto Color Emoji', }; +if (getOperatingSystem() === CONST.OS.WINDOWS) { + Object.keys(fontFamily).forEach((key) => { + fontFamily[key as keyof FontFamilyStyles] = fontFamily[key as keyof FontFamilyStyles].replace('Segoe UI Emoji', 'Windows Segoe UI Emoji'); + }); +} + export default fontFamily; diff --git a/src/styles/styles.ts b/src/styles/styles.ts index 404c5983d7f7..73f9aa823f40 100644 --- a/src/styles/styles.ts +++ b/src/styles/styles.ts @@ -19,7 +19,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'; @@ -80,7 +80,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, @@ -96,14 +96,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, @@ -116,7 +116,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 @@ -211,7 +211,7 @@ const webViewStyles = (theme: ThemeDefault) => }, } satisfies WebViewStyle); -const styles = (theme: ThemeDefault) => +const styles = (theme: ThemeColors) => ({ // Add all of our utility and helper styles ...spacing, @@ -3573,7 +3573,8 @@ const styles = (theme: ThemeDefault) => googlePillButtonContainer: { colorScheme: 'light', height: 40, - width: 219, + width: 300, + overflow: 'hidden', }, thirdPartyLoadingContainer: { @@ -4013,12 +4014,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.d.ts b/src/types/modules/react-native-web.d.ts new file mode 100644 index 000000000000..f7db951eadad --- /dev/null +++ b/src/types/modules/react-native-web.d.ts @@ -0,0 +1,11 @@ +/* eslint-disable import/prefer-default-export */ +/* eslint-disable @typescript-eslint/consistent-type-definitions */ +declare module 'react-native-web' { + class Clipboard { + static isAvailable(): boolean; + static getString(): Promise; + static setString(text: string): boolean; + } + + export {Clipboard}; +} 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,