diff --git a/.github/actions/composite/buildAndroidE2EAPK/action.yml b/.github/actions/composite/buildAndroidE2EAPK/action.yml index 0c5f70929c27..146ddb3a1a66 100644 --- a/.github/actions/composite/buildAndroidE2EAPK/action.yml +++ b/.github/actions/composite/buildAndroidE2EAPK/action.yml @@ -58,21 +58,15 @@ runs: - name: Append environment variables to env file shell: bash run: | - echo "EXPENSIFY_PARTNER_NAME=${EXPENSIFY_PARTNER_NAME}" >> ${{ inputs.PATH_ENV_FILE }} - echo "EXPENSIFY_PARTNER_PASSWORD=${EXPENSIFY_PARTNER_PASSWORD}" >> ${{ inputs.PATH_ENV_FILE }} - echo "EXPENSIFY_PARTNER_USER_ID=${EXPENSIFY_PARTNER_USER_ID}" >> ${{ inputs.PATH_ENV_FILE }} - echo "EXPENSIFY_PARTNER_USER_SECRET=${EXPENSIFY_PARTNER_USER_SECRET}" >> ${{ inputs.PATH_ENV_FILE }} - echo "EXPENSIFY_PARTNER_PASSWORD_EMAIL=${EXPENSIFY_PARTNER_PASSWORD_EMAIL}" >> ${{ inputs.PATH_ENV_FILE }} + echo "EXPENSIFY_PARTNER_NAME=${{ inputs.EXPENSIFY_PARTNER_NAME }}" >> ${{ inputs.PATH_ENV_FILE }} + echo "EXPENSIFY_PARTNER_PASSWORD=${{ inputs.EXPENSIFY_PARTNER_PASSWORD }}" >> ${{ inputs.PATH_ENV_FILE }} + echo "EXPENSIFY_PARTNER_USER_ID=${{ inputs.EXPENSIFY_PARTNER_USER_ID }}" >> ${{ inputs.PATH_ENV_FILE }} + echo "EXPENSIFY_PARTNER_USER_SECRET=${{ inputs.EXPENSIFY_PARTNER_USER_SECRET }}" >> ${{ inputs.PATH_ENV_FILE }} + echo "EXPENSIFY_PARTNER_PASSWORD_EMAIL=${{ inputs.EXPENSIFY_PARTNER_PASSWORD_EMAIL }}" >> ${{ inputs.PATH_ENV_FILE }} - name: Build APK run: npm run ${{ inputs.PACKAGE_SCRIPT_NAME }} shell: bash - env: - EXPENSIFY_PARTNER_NAME: ${{ inputs.EXPENSIFY_PARTNER_NAME }} - EXPENSIFY_PARTNER_PASSWORD: ${{ inputs.EXPENSIFY_PARTNER_PASSWORD }} - EXPENSIFY_PARTNER_USER_ID: ${{ inputs.EXPENSIFY_PARTNER_USER_ID }} - EXPENSIFY_PARTNER_USER_SECRET: ${{ inputs.EXPENSIFY_PARTNER_USER_SECRET }} - EXPENSIFY_PARTNER_PASSWORD_EMAIL: ${{ inputs.EXPENSIFY_PARTNER_PASSWORD_EMAIL }} - name: Upload APK uses: actions/upload-artifact@65d862660abb392b8c4a3d1195a2108db131dd05 diff --git a/.github/workflows/e2ePerformanceTests.yml b/.github/workflows/e2ePerformanceTests.yml index 70f70fca60de..31bfdf963525 100644 --- a/.github/workflows/e2ePerformanceTests.yml +++ b/.github/workflows/e2ePerformanceTests.yml @@ -46,7 +46,7 @@ jobs: git fetch origin tag ${{ steps.getMostRecentRelease.outputs.VERSION }} --no-tags --depth=1 git switch --detach ${{ steps.getMostRecentRelease.outputs.VERSION }} - - uses: ./.github/actions/composite/buildAndroidE2EAPK + - uses: Expensify/App/.github/actions/composite/buildAndroidE2EAPK@main with: ARTIFACT_NAME: baseline-apk-${{ steps.getMostRecentRelease.outputs.VERSION }} PACKAGE_SCRIPT_NAME: android-build-e2e @@ -114,7 +114,7 @@ jobs: - name: Checkout "delta ref" run: git checkout ${{ steps.getDeltaRef.outputs.DELTA_REF }} - - uses: ./.github/actions/composite/buildAndroidE2EAPK + - uses: Expensify/App/.github/actions/composite/buildAndroidE2EAPK@main with: ARTIFACT_NAME: delta-apk-${{ steps.getDeltaRef.outputs.DELTA_REF }} PACKAGE_SCRIPT_NAME: android-build-e2edelta diff --git a/__mocks__/rn-fetch-blob.js b/__mocks__/rn-fetch-blob.js deleted file mode 100644 index 4d179e730903..000000000000 --- a/__mocks__/rn-fetch-blob.js +++ /dev/null @@ -1,4 +0,0 @@ -export default { - DocumentDir: jest.fn(), - ImageCache: jest.fn(), -}; diff --git a/android/app/build.gradle b/android/app/build.gradle index 4ea2098787f6..a53c6dfb62af 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -98,8 +98,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001043500 - versionName "1.4.35-0" + versionCode 1001043600 + versionName "1.4.36-0" } flavorDimensions "default" diff --git a/assets/images/cards-and-domains.svg b/assets/images/cards-and-domains.svg new file mode 100644 index 000000000000..4467ad4cf222 --- /dev/null +++ b/assets/images/cards-and-domains.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/images/home.svg b/assets/images/home.svg new file mode 100644 index 000000000000..6b2411407be7 --- /dev/null +++ b/assets/images/home.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/images/new-expensify.svg b/assets/images/new-expensify.svg index 7bfef1fd38b4..89102ecbc5e4 100644 --- a/assets/images/new-expensify.svg +++ b/assets/images/new-expensify.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/images/wrench.svg b/assets/images/wrench.svg new file mode 100644 index 000000000000..2865c40eb952 --- /dev/null +++ b/assets/images/wrench.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/contributingGuides/APPLE_GOOGLE_SIGNIN.md b/contributingGuides/APPLE_GOOGLE_SIGNIN.md index 43485a28b353..4bb86e31b486 100644 --- a/contributingGuides/APPLE_GOOGLE_SIGNIN.md +++ b/contributingGuides/APPLE_GOOGLE_SIGNIN.md @@ -259,12 +259,33 @@ if (CONFIG.ENVIRONMENT === CONST.ENVIRONMENT.DEV) { } ``` -#### Port requirements +#### Host/Port requirements Google allows the web app to be hosted at localhost, but according to the current Google console configuration for the Expensify client ID, it must be hosted on port 8082. +Also note that you'll need to update the webpack.dev.js config to change `host` from `dev.new.expensify.com` to `localhost` and server type from `https` to `http`. The reason for this is that Google Sign In allows localhost, but `dev.new.expensify.com` is not a registered Google Sign In domain. + +```diff +diff --git a/config/webpack/webpack.dev.js b/config/webpack/webpack.dev.js +index e28383eff5..b14f6f34aa 100644 +--- a/config/webpack/webpack.dev.js ++++ b/config/webpack/webpack.dev.js +@@ -44,9 +44,9 @@ module.exports = (env = {}) => + ...proxySettings, + historyApiFallback: true, + port, +- host: 'dev.new.expensify.com', ++ host: 'localhost', + server: { +- type: 'https', ++ type: 'http', + options: { + key: path.join(__dirname, 'key.pem'), + cert: path.join(__dirname, 'certificate.pem'), +``` + ### Desktop #### Set Environment to something other than "Development" diff --git a/docs/_includes/end-info.html b/docs/_includes/end-info.html new file mode 100644 index 000000000000..50b9e11847ef --- /dev/null +++ b/docs/_includes/end-info.html @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/docs/_includes/end-option.html b/docs/_includes/end-option.html new file mode 100644 index 000000000000..7f5eaa32ef17 --- /dev/null +++ b/docs/_includes/end-option.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/_includes/end-selector.html b/docs/_includes/end-selector.html new file mode 100644 index 000000000000..7f5eaa32ef17 --- /dev/null +++ b/docs/_includes/end-selector.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/_includes/info.html b/docs/_includes/info.html new file mode 100644 index 000000000000..c253f3cbc1de --- /dev/null +++ b/docs/_includes/info.html @@ -0,0 +1,3 @@ +
+ +
\ No newline at end of file diff --git a/docs/_includes/option.html b/docs/_includes/option.html new file mode 100644 index 000000000000..0168c15dc97e --- /dev/null +++ b/docs/_includes/option.html @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/docs/_includes/selector.html b/docs/_includes/selector.html new file mode 100644 index 000000000000..be27578a519a --- /dev/null +++ b/docs/_includes/selector.html @@ -0,0 +1,9 @@ +{% assign values = include.values | split: "," %} + +
+ + diff --git a/docs/_layouts/default.html b/docs/_layouts/default.html index 7d98500ecf32..99f4b22b473c 100644 --- a/docs/_layouts/default.html +++ b/docs/_layouts/default.html @@ -12,6 +12,8 @@ + + @@ -64,10 +66,13 @@
{% if page.url contains "/articles/" %} -

- {{ page.name | remove: '.md' | split: "-" | join: " " }} -

- +
+

+ {{ page.name | remove: '.md' | split: "-" | join: " " }} +

+
+
+
{{ content }}
diff --git a/docs/_sass/_main.scss b/docs/_sass/_main.scss index cfdf4ff3a2bc..ea18acef7c23 100644 --- a/docs/_sass/_main.scss +++ b/docs/_sass/_main.scss @@ -350,10 +350,14 @@ button { h1 { &.title { font-size: 2.25em; + flex: 1; } } .article { + .hidden { + display: none; + } img { display: block; margin: 20px auto; @@ -458,6 +462,69 @@ button { opacity: 0.8; } } + + .selector-container { + background-color: $color-highlightBG; + display: flex; + flex-direction: row-reverse; + gap: 20px; + border-radius: 12px; + padding: 20px; + margin-bottom: 20px; + justify-content: space-between; + * > ol, ul { + padding: 0; + } + + @include maxBreakpoint($breakpoint-tablet) { + flex-direction: column; + } + } + + select { + height: 28px; + border-radius: 20px; + padding: 0px 26px 0px 12px; + color: $color-text; + font-size: 11px; + font-weight: 700; + text-align: center; + cursor: pointer; + + @include maxBreakpoint($breakpoint-tablet) { + width: 100px; + } + + } + + select { + background: url("/assets/images/down.svg") no-repeat right $color-button-background; + background-size: 12px; + background-position-x: 85%; + appearance: none !important; + -moz-appearance: none !important; + -webkit-appearance: none !important; + } + + .info { + padding: 12px; + border-radius: 8px; + background-color: $color-highlightBG; + color: $color-text; + display: flex; + gap: 12px; + align-items: center; + + img { + height: 16px; + width: 16px; + } + + * { + padding: 0; + margin: 0; + } + } } } @@ -842,3 +909,45 @@ button { } } } + +.title-platform-tabs { + display: flex; + justify-content: space-between; + padding-bottom: 12px; + h1 { + padding: 0; + } + + @include maxBreakpoint($breakpoint-tablet) { + flex-direction: column; + gap: 20px; + } +} + +#platform-tabs { + display: flex; + flex-wrap: wrap; + align-items: center; + text-align: center; + font-weight: 700; + font-size: 13px; + gap: 4px; +} + +#platform-tabs > * { + cursor: pointer; + border-radius: 20px; + padding: 10px 20px; + box-sizing: border-box; + height: 36px; + line-height: 16px; +} + +#platform-tabs > .active { + color: $color-text; + background-color: $color-button-background; +} + +.hidden { + display: none; +} diff --git a/docs/articles/expensify-classic/getting-started/Join-your-company's-workspace.md b/docs/articles/expensify-classic/getting-started/Join-your-company's-workspace.md new file mode 100644 index 000000000000..099f381e6010 --- /dev/null +++ b/docs/articles/expensify-classic/getting-started/Join-your-company's-workspace.md @@ -0,0 +1,258 @@ +--- +title: Join your company's workspace +description: Get started with Expensify as an employee or other company member +--- +
+ +# Overview + +Welcome to Expensify! If you received an invitation to join your company’s Expensify workspace, follow the steps below to get started. + +# 1. Download the mobile app + +Upload your expenses and check your reports right from your phone by downloading the Expensify mobile app. You can search for “Expensify” in the app store, or tap one of the links below. + +[iOS](https://apps.apple.com/us/app/expensify-expense-tracker/id471713959) +| [Android](https://play.google.com/store/apps/details?id=org.me.mobiexpensifyg&hl=en_US&gl=US) + +# 2. Add your name and photo + +{% include selector.html values="desktop, mobile" %} +{% include option.html value="desktop" %} +
    +
  1. Click the profile image at the top of the main menu.
  2. +
  3. Hover over the profile picture and click Change.
  4. +
  5. Update your profile picture and name. +
      +
    • Name: Enter your first and last name into the fields and click Update. Note that this name will be visible to anyone in your company workspace.
    • +
    • Photo: Click Add Photo.
    • +
    +
  6. +
+ +{% include end-option.html %} + +{% include option.html value="mobile" %} + +
    +
  1. Tap the ☰ menu icon in the top left.
  2. +
  3. Tap the profile picture icon.
  4. +
  5. Tap the Edit icon next to your name and update your name or photo. +
      +
    • Name: Enter your first and/or last name into the fields and tap Update. Note that this name will be visible to anyone in your company workspace.
    • +
    • Photo: Tap Upload Photo and either:
    • +
        +
      • Tap the capture button to take a new photo.
      • +
      • Tap the photo icon on the left to select a saved photo.
      • +
      +
    +
  6. +
+ +{% include end-option.html %} +{% include end-selector.html %} + + +# 3. Meet Concierge +Your personal assistant, Concierge, lives on your Expensify Home page on both desktop and the mobile app. + +Concierge will walk you through setting up your account and also provide: +
    +
  • Reminders to do things like submit your expenses
  • +
  • Alerts when more information is needed on an expense report
  • +
  • Updates on new and improved account features
  • +
+ +You can also get support at any time by clicking the green chat bubble in the right corner. This will open a chat with Concierge where you can ask questions and receive direct support. + +# 4. Learn how to add an expense +As an employee, you may need to document reimbursable expenses (like business travel paid for with personal funds) or non-reimbursable expenses (like a lunch paid for with a company card). You can create an expense automatically by SmartScanning a receipt, or you can enter them manually. + +## SmartScan a receipt + +You can upload pictures of your receipts to Expensify and SmartScan will automatically capture the receipt details including the merchant, date, total, and currency. + +{% include selector.html values="desktop, mobile" %} +{% include option.html value="desktop" %} +
    +
  1. Click the Expenses tab.
  2. +
  3. Click the + icon in the top right and select Scan Receipt.
  4. +
  5. Upload a saved image of a receipt.
  6. +
+ +{% include end-option.html %} + +{% include option.html value="mobile" %} +
    +
  1. Open the mobile app and tap the camera icon in the bottom right corner.
  2. +
  3. Upload or take a photo of your receipt.
  4. +
      +
    • Upload a photo: Click the photo icon in the left corner and select the image from your device.
    • +
    • Take a photo: Click the camera icon in the right corner to select the mode, make sure all of the transaction details are clearly visible, and then take the photo.
    • +
    +
  5. Normal Mode: Upload one receipt.
  6. +
  7. Rapid Fire Mode: Upload multiple receipts at once.
  8. +
+{% include end-option.html %} +{% include end-selector.html %} + +You can open any receipt and select **Fill out details myself** to add or edit the merchant, date, total, description, category, or add attendees who took part in the expense. You can also check that the expense is correctly labeled as reimbursable or non-reimbursable, and split the expense if multiple expenses are included on one receipt. + +*Note: You can also email receipts to SmartScan by sending them to receipts@expensify.com from an email address tied to your Expensify account (either a primary or secondary email). SmartScan will automatically pull all of the details from the receipt, fill them in for you, and add the receipt to the Expenses tab on your account.* + +## Manually enter an expense + +{% include selector.html values="desktop, mobile" %} + +{% include option.html value="desktop" %} +
    +
  1. Click the Expenses tab.
  2. +
  3. Click the + icon in the top right.
  4. +
  5. Select the type of expense and enter the expense details.
  6. +
      +
    • Manually create: Manually enter receipt details.
    • +
    • Scan receipt: Upload a saved image of a receipt.
    • +
    • Create multiple: Upload expenses in bulk.
    • +
    • Time: Create an expense based on hours.
    • +
    • Distance: Create an expense based on distance.
    • +
        +
      • Manually Create: Manually enter the distance details for the expense.
      • +
      • Create from Map: Enter the start and end destination and Expensify will help you create a receipt for the trip.
      • +
      +
    +
  7. Click Save.
  8. +
+{% include end-option.html %} + +{% include option.html value="mobile" %} +
    +
  1. Tap the ☰ menu icon in the top left.
  2. +
  3. Tap Expenses.
  4. +
  5. Tap the + icon in the top right.
  6. +
  7. Tap the correct expense type and enter the expense details.
  8. +
      +
    • Manually create: Manually enter receipt details.
    • +
    • Time: Enter work time and rate.
    • +
    • Manually create (Distance): Manually enter trip details by total distance.
    • +
    • Odometer: Manually enter trip details by start and end odometer readings.
    • +
    • Start GPS: Track distance while using the Expensify app to automatically calculate the distance in real time during the trip.
    • +
    +
  9. Tap Save.
  10. +
+{% include end-option.html %} + +{% include end-selector.html %} + +# 5. Learn how to create & submit an expense report + +Once you’ve created your expenses, they may be automatically added to an expense report if your company has this feature enabled. If not, your next step will be to add your expenses to a report and submit them for payment. + +{% include selector.html values="Desktop, Mobile" %} + +{% include option.html value="desktop" %} + +
    +
  1. Click the Reports tab.
  2. +
      +
    • If a report has been automatically created for your most recently submitted expense, then you don’t have to do anything else—your report is already created and will also be automatically submitted.
    • +
    • If a report has not been automatically created, follow the steps below.
    • +
    +
  3. Click New Report, or click the New Report dropdown and select Expense Report.
  4. +
  5. Click Add Expenses.
  6. +
  7. Click an expense to add it to the report.
  8. +
      +
    • If an expense you already added does not appear in the list, use the filter on the left to search by the merchant name or change the date range. Note: Only expenses that are not already on a report will appear.
    • +
    +
  9. Once all your expenses are added to the report, click the X to close the pop-up.
  10. +
  11. (Optional) Make any desired changes to the report and/or expenses.
  12. +
      +
    • Click the Edit icon next to the report name to change it. If this icon is not visible, the option has been disabled by your workspace.
    • +
    • Click the X icon next to an expense to remove it from the report.
    • +
    • Click the Expense Details icon to review or edit the expense details.
    • +
    • At the bottom of the report, add comments to include more information.
    • +
    • Click the Attachments icon to add additional attachments.
    • +
    +
  13. When the report is ready to send for approval, click Submit.
  14. +
  15. Enter the details for who will receive a notification email about your report and what they will receive.
  16. +
      +
    • To: Enter the name(s) who will be approving your report (if they are not already listed).
    • +
    • CC: Enter the email address of anyone else who should be notified that your expense report has been submitted. Add a comma between each email address if adding more than one.
    • +
    • Memo: Enter any relevant notes.
    • +
    • Attach PDF: Select this checkbox to attach a copy of your report to the email.
    • +
    +
  17. Click Send.
  18. +
+ +{% include end-option.html %} + +{% include option.html value="mobile" %} +
    +
  1. Tap the ☰ menu icon in the top left.
  2. +
  3. Tap Reports.
  4. +
      +
    • If a report has been automatically created for your most recently submitted expense, then you don’t have to do anything else—your report is already created and will also be automatically submitted.
    • +
    • If a report has not been automatically created, follow the steps below.
    • +
    +
  5. Tap the + icon and tap Expense Report.
  6. +
  7. Tap Add Expenses, then tap an expense to add it to the report. Repeat this step until all desired expenses are added. Note: Only expenses that are not already on a report will appear.
  8. +
  9. (Optional) Make any desired changes to the report and/or expenses.
  10. +
      +
    • Tap the report name to change it.
    • +
    • Tap an expense to review or edit the expense details.
    • +
    • At the bottom of the report, add comments to include more information.
    • +
    • Tap the Attachments icon to add additional attachments.
    • +
    +
  11. When the report is ready to send for approval, tap Submit Report.
  12. +
  13. Add any additional sending details and tap Submit.
  14. +
  15. Enter the details for who will receive a notification email about your report and what they will receive.
  16. +
      +
    • To: Enter the name(s) who will be approving your report (if they are not already listed).
    • +
    • CC: Enter the email address of anyone else who should be notified that your expense report has been submitted. Add a comma between each email address if adding more than one.
    • +
    • Memo: Enter any relevant notes.
    • +
    • Attach PDF: Select this checkbox to attach a copy of your report to the email.
    • +
    +
  17. Tap Submit.
  18. + +
+{% include end-option.html %} + +{% include end-selector.html %} + +# 6. Add a secondary login + +Connect your personal email address as a secondary login so you always have access to your Expensify account, even if your employer changes. + +*Note: This process is currently not available from the mobile app and must be completed from the Expensify website.* + +
    +
  1. Hover over Settings, then click Account.
  2. +
  3. Under the Account Details tab, scroll down to the Secondary Logins section and click Add Secondary Login.
  4. +
  5. Enter the email address or phone number you wish to use as a secondary login. For phone numbers, be sure to include the international code, if applicable.
  6. +
  7. Find the email or text message from Expensify containing the Magic Code and enter it into the field to add the secondary login.
  8. +
+ +# 7. Secure your account + +Add an extra layer of security to help keep your financial data safe and secure by enabling two-factor authentication. This will require you to enter a code generated by your preferred authenticator app (like Google Authenticator or Microsoft Authenticator) when you log in. + +*Note: This process is currently not available from the mobile app and must be completed from the Expensify website.* + +
    +
  1. Hover over Settings, then click Account.
  2. +
  3. Under the Account Details tab, scroll down to the Two Factor Authentication section and enable the toggle.
  4. +
  5. Save a copy of your backup codes. This step is critical—You will lose access to your account if you cannot use your authenticator app and do not have your recovery codes.
  6. +
      +
    • Click Download to save a copy of your backup codes to your computer.
    • +
    • Click Copy to paste the codes into a document or other secure location.
    • +
    +
  7. Click Continue.
  8. +
  9. Download or open your authenticator app and either:
  10. +
      +
    • Scan the QR code shown on your computer screen.
    • +
    • Enter the 6-digit code from your authenticator app into Expensify and click Verify.
    • +
    +
+ +When you log in to Expensify in the future, you’ll open your authenticator app to get the code and enter it into Expensify. A new code regenerates every few seconds, so the code is always different. If the code time runs out, you can generate a new code as needed. + +
\ No newline at end of file diff --git a/docs/articles/expensify-classic/getting-started/Security.md b/docs/articles/expensify-classic/getting-started/Security.md deleted file mode 100644 index 5a0036e3e161..000000000000 --- a/docs/articles/expensify-classic/getting-started/Security.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Security -description: Security ---- -## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/getting-started/Using-The-App.md b/docs/articles/expensify-classic/getting-started/Using-The-App.md index 9b8bc530e12e..f1bc31793ba8 100644 --- a/docs/articles/expensify-classic/getting-started/Using-The-App.md +++ b/docs/articles/expensify-classic/getting-started/Using-The-App.md @@ -2,6 +2,8 @@ title: Using the app description: Streamline expense management effortlessly with the Expensify mobile app. Learn how to install, enable push notifications, and use SmartScan to capture, categorize, and track expenses. Versatile for personal and business use, Expensify is a secure and automated solution for managing your finances on the go. --- + +
# Overview The Expensify mobile app is the ultimate expense management solution that makes it effortless to track and submit your receipts and expenses. Use the app to snap a picture of your receipts, categorize and submit expenses, and even review and approve expense reports. # How to install the Expensify app @@ -9,7 +11,7 @@ To get started with Expensify on your mobile device, you need to download the ap 1. Visit the App Store (iOS) or Google Play Store (Android). 2. Search for "Expensify" and select the official Expensify app. 3. Tap "Download" or "Install." - + Once the app is installed, open it and log in with your Expensify credentials. If you don't have an Expensify account, you can create one during the sign-up process. # How to enable on push notifications Push notifications keep you informed about expense approvals, reimbursements, and more. To enable push notifications: @@ -54,3 +56,10 @@ Expensify takes security seriously and employs encryption and other security mea Yes, you can use the mobile app offline to capture receipts and create expenses. The app will sync your data once you have an internet connection. {% include faq-end.md %} +
+
+ +# Coming soon + + +
\ No newline at end of file diff --git a/docs/assets/images/info.svg b/docs/assets/images/info.svg new file mode 100644 index 000000000000..96924fbb6cf7 --- /dev/null +++ b/docs/assets/images/info.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/docs/assets/js/main.js b/docs/assets/js/main.js index 6e154bb09a44..6b3390148ff0 100644 --- a/docs/assets/js/main.js +++ b/docs/assets/js/main.js @@ -165,6 +165,37 @@ window.addEventListener('load', () => { insertElementAfter(searchInput, searchLabel); }); +const tocbotOptions = { + // Where to render the table of contents. + tocSelector: '.article-toc', + + // Where to grab the headings to build the table of contents. + contentSelector: '', + + // Disable the collapsible functionality of the library by + // setting the maximum number of heading levels (6) + collapseDepth: 6, + headingSelector: 'h1, h2, h3, summary', + + // Main class to add to lists. + listClass: 'lhn-items', + + // Main class to add to links. + linkClass: 'link', + + // Class to add to active links, + // the link corresponding to the top most heading on the page. + activeLinkClass: 'selected-article', + + // Headings offset between the headings and the top of the document (requires scrollSmooth enabled) + headingsOffset: 80, + scrollSmoothOffset: -80, + scrollSmooth: true, + + // If there is a fixed article scroll container, set to calculate titles' offset + scrollContainer: 'content-area', +}; + window.addEventListener('DOMContentLoaded', () => { injectFooterCopywrite(); @@ -179,38 +210,51 @@ window.addEventListener('DOMContentLoaded', () => { buttonCloseSidebar.addEventListener('click', closeSidebar); } - if (window.tocbot) { - window.tocbot.init({ - // Where to render the table of contents. - tocSelector: '.article-toc', + const expensifyClassicTab = document.getElementById('platform-tab-expensify-classic'); + const newExpensifyTab = document.getElementById('platform-tab-new-expensify'); - // Where to grab the headings to build the table of contents. - contentSelector: '.article-toc-content', + const expensifyClassicContent = document.getElementById('expensify-classic'); + const newExpensifyContent = document.getElementById('new-expensify'); - // Disable the collapsible functionality of the library by - // setting the maximum number of heading levels (6) - collapseDepth: 6, - headingSelector: 'h1, h2, h3, summary', + let contentSelector = '.article-toc-content'; + if (expensifyClassicContent) { + contentSelector = '#expensify-classic'; + } else if (newExpensifyContent) { + contentSelector = '#new-expensify'; + } - // Main class to add to lists. - listClass: 'lhn-items', + if (window.tocbot) { + window.tocbot.init({ + ...tocbotOptions, + contentSelector, + }); + } - // Main class to add to links. - linkClass: 'link', + // eslint-disable-next-line es/no-optional-chaining + expensifyClassicTab?.addEventListener('click', () => { + expensifyClassicTab.classList.add('active'); + expensifyClassicContent.classList.remove('hidden'); - // Class to add to active links, - // the link corresponding to the top most heading on the page. - activeLinkClass: 'selected-article', + newExpensifyTab.classList.remove('active'); + newExpensifyContent.classList.add('hidden'); + window.tocbot.refresh({ + ...tocbotOptions, + contentSelector: '#expensify-classic', + }); + }); - // Headings offset between the headings and the top of the document (requires scrollSmooth enabled) - headingsOffset: 80, - scrollSmoothOffset: -80, - scrollSmooth: true, + // eslint-disable-next-line es/no-optional-chaining + newExpensifyTab?.addEventListener('click', () => { + newExpensifyTab.classList.add('active'); + newExpensifyContent.classList.remove('hidden'); - // If there is a fixed article scroll container, set to calculate titles' offset - scrollContainer: 'content-area', + expensifyClassicTab.classList.remove('active'); + expensifyClassicContent.classList.add('hidden'); + window.tocbot.refresh({ + ...tocbotOptions, + contentSelector: '#new-expensify', }); - } + }); document.getElementById('header-button').addEventListener('click', toggleHeaderMenu); diff --git a/docs/assets/js/platform-tabs.js b/docs/assets/js/platform-tabs.js new file mode 100644 index 000000000000..e677e58b1e97 --- /dev/null +++ b/docs/assets/js/platform-tabs.js @@ -0,0 +1,22 @@ +const expensifyClassicContent = document.getElementById('expensify-classic'); +const newExpensifyContent = document.getElementById('new-expensify'); +const platformTabs = document.getElementById('platform-tabs'); + +if (expensifyClassicContent) { + const tab = document.createElement('div'); + tab.innerHTML = 'Expensify Classic'; + tab.id = 'platform-tab-expensify-classic'; + tab.classList.add('active'); + platformTabs.appendChild(tab); +} + +if (newExpensifyContent) { + const tab = document.createElement('div'); + tab.innerHTML = 'New Expensify'; + tab.id = 'platform-tab-new-expensify'; + + if (!expensifyClassicContent) { + tab.classList.add('active'); + } + platformTabs.appendChild(tab); +} diff --git a/docs/assets/js/selector.js b/docs/assets/js/selector.js new file mode 100644 index 000000000000..7373c7892767 --- /dev/null +++ b/docs/assets/js/selector.js @@ -0,0 +1,35 @@ +function syncSelectors(selectedIndex) { + const allSelects = document.querySelectorAll('select'); + for (let i = 0; i < allSelects.length; i++) { + allSelects[i].selectedIndex = selectedIndex; + } +} + +function selectOption(select) { + if (!select) { + return; + } + + syncSelectors(select.selectedIndex); + + const allOptions = Array.from(select.options); + const selectedValue = select.options[select.selectedIndex].value; + + // Hide section that isn't selected, and show section that is selected. + allOptions.forEach((option) => { + if (option.value === selectedValue) { + const toShow = document.getElementsByClassName(option.value); + for (let i = 0; i < toShow.length; i++) { + toShow[i].classList.remove('hidden'); + } + return; + } + + const toHide = document.getElementsByClassName(option.value); + for (let i = 0; i < toHide.length; i++) { + toHide[i].classList.add('hidden'); + } + }); +} + +window.onload = selectOption(document.getElementsByClassName('selector')[0]); diff --git a/docs/redirects.csv b/docs/redirects.csv index 2571cb1156eb..df615431533f 100644 --- a/docs/redirects.csv +++ b/docs/redirects.csv @@ -33,3 +33,19 @@ https://community.expensify.com/discussion/6794/how-to-change-your-email-in-expe https://help.expensify.com/articles/expensify-classic/expensify-card/Expensify-Card-Perks.html,https://use.expensify.com/company-credit-card https://help.expensify.com/articles/expensify-classic/expensify-partner-program/How-to-Join-the-ExpensifyApproved!-Partner-Program.html,https://use.expensify.com/accountants-program https://help.expensify.com/articles/expensify-classic/getting-started/approved-accountants/Card-Revenue-Share-For-Expensify-Approved-Partners, https://use.expensify.com/blog/maximizing-rewards-expensifyapproved-accounting-partners-now-earn-0-5-revenue-share +https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/International-Reimbursements,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/Global-Reimbursements +https://community.expensify.com/discussion/4452/how-to-merge-accounts,https://help.expensify.com/articles/expensify-classic/account-settings/Merge-Accounts#gsc.tab=0 +https://community.expensify.com/discussion/4783/how-to-add-or-remove-a-copilot#latest,https://help.expensify.com/articles/expensify-classic/account-settings/Copilot#gsc.tab=0 +https://community.expensify.com/discussion/4343/expensify-anz-partnership-announcement,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Connect-ANZ +https://community.expensify.com/discussion/7318/deep-dive-company-credit-card-import-options,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards +https://community.expensify.com/discussion/2673/personalize-your-commercial-card-feed-name,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Commercial-Card-Feeds +https://community.expensify.com/discussion/6569/how-to-import-and-assign-company-cards-from-csv-file,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/CSV-Import +https://community.expensify.com/discussion/4714/how-to-set-up-a-direct-bank-connection-for-company-cards,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Direct-Bank-Connections +https://community.expensify.com/discussion/4828/how-to-reconcile-your-company-cards-statement-in-expensify,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Reconciliation +https://community.expensify.com/discussion/5366/deep-dive-troubleshooting-credit-card-issues-in-expensify,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Troubleshooting +https://community.expensify.com/discussion/9554/how-to-set-up-global-reimbursemen,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/Global-Reimbursements +https://community.expensify.com/discussion/4463/how-to-remove-or-manage-settings-for-imported-personal-cards,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/Personal-Credit-Cards +https://community.expensify.com/discussion/5793/how-to-connect-your-personal-card-to-import-expenses,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/Personal-Credit-Cards +https://community.expensify.com/discussion/4826/how-to-set-your-annual-subscription-size,https://help.expensify.com/articles/expensify-classic/billing-and-subscriptions/Annual-Subscription +https://community.expensify.com/discussion/5667/deep-dive-how-does-the-annual-subscription-billing-work,https://help.expensify.com/articles/expensify-classic/billing-and-subscriptions/Annual-Subscription +https://help.expensify.com/expensify-classic/hubs/getting-started/plan-types,https://www.expensify.com/pricing diff --git a/ios/NewExpensify.xcodeproj/project.pbxproj b/ios/NewExpensify.xcodeproj/project.pbxproj index 41f53a0b8f7d..5d3bf1c07985 100644 --- a/ios/NewExpensify.xcodeproj/project.pbxproj +++ b/ios/NewExpensify.xcodeproj/project.pbxproj @@ -615,6 +615,7 @@ "${PODS_CONFIGURATION_BUILD_DIR}/Airship/AirshipPreferenceCenterResources.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/GoogleSignIn/GoogleSignIn.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/React-Core/RCTI18nStrings.bundle", + "${PODS_ROOT}/../../node_modules/@expensify/react-native-live-markdown/parser/react-native-live-markdown-parser.js", ); name = "[CP] Copy Pods Resources"; outputPaths = ( @@ -625,6 +626,7 @@ "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/AirshipPreferenceCenterResources.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleSignIn.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RCTI18nStrings.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/react-native-live-markdown-parser.js", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; @@ -784,6 +786,7 @@ "${PODS_CONFIGURATION_BUILD_DIR}/Airship/AirshipPreferenceCenterResources.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/GoogleSignIn/GoogleSignIn.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/React-Core/RCTI18nStrings.bundle", + "${PODS_ROOT}/../../node_modules/@expensify/react-native-live-markdown/parser/react-native-live-markdown-parser.js", ); name = "[CP] Copy Pods Resources"; outputPaths = ( @@ -794,6 +797,7 @@ "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/AirshipPreferenceCenterResources.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleSignIn.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RCTI18nStrings.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/react-native-live-markdown-parser.js", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 341edd0c9dfe..6a6a54e58c49 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.4.35 + 1.4.36 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 1.4.35.0 + 1.4.36.0 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 6b39db8b2f27..763896fd45f1 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.4.35 + 1.4.36 CFBundleSignature ???? CFBundleVersion - 1.4.35.0 + 1.4.36.0 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index ff9b56d72408..a4bef0847c95 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 1.4.35 + 1.4.36 CFBundleVersion - 1.4.35.0 + 1.4.36.0 NSExtension NSExtensionPointIdentifier diff --git a/ios/Podfile.lock b/ios/Podfile.lock index c3a0a534d9b4..584dd5636773 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1178,6 +1178,10 @@ PODS: - React-Core - react-native-launch-arguments (4.0.2): - React + - react-native-live-markdown (0.1.0): + - glog + - RCT-Folly (= 2022.05.16.00) + - React-Core - react-native-netinfo (11.2.1): - React-Core - react-native-pager-view (6.2.2): @@ -1525,6 +1529,7 @@ DEPENDENCIES: - react-native-image-picker (from `../node_modules/react-native-image-picker`) - react-native-key-command (from `../node_modules/react-native-key-command`) - react-native-launch-arguments (from `../node_modules/react-native-launch-arguments`) + - "react-native-live-markdown (from `../node_modules/@expensify/react-native-live-markdown`)" - "react-native-netinfo (from `../node_modules/@react-native-community/netinfo`)" - react-native-pager-view (from `../node_modules/react-native-pager-view`) - react-native-pdf (from `../node_modules/react-native-pdf`) @@ -1720,6 +1725,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-key-command" react-native-launch-arguments: :path: "../node_modules/react-native-launch-arguments" + react-native-live-markdown: + :path: "../node_modules/@expensify/react-native-live-markdown" react-native-netinfo: :path: "../node_modules/@react-native-community/netinfo" react-native-pager-view: @@ -1915,6 +1922,7 @@ SPEC CHECKSUMS: react-native-image-picker: c33d4e79f0a14a2b66e5065e14946ae63749660b react-native-key-command: 5af6ee30ff4932f78da6a2109017549042932aa5 react-native-launch-arguments: 5f41e0abf88a15e3c5309b8875d6fd5ac43df49d + react-native-live-markdown: 1ca4275d4dba8eebb736a005148f24a8224f54d9 react-native-netinfo: 8a7fd3f7130ef4ad2fb4276d5c9f8d3f28d2df3d react-native-pager-view: 02a5c4962530f7efc10dd51ee9cdabeff5e6c631 react-native-pdf: b4ca3d37a9a86d9165287741c8b2ef4d8940c00e diff --git a/package-lock.json b/package-lock.json index acadf891d8d7..674325508da5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,17 @@ { "name": "new.expensify", - "version": "1.4.35-0", + "version": "1.4.36-0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.4.35-0", + "version": "1.4.36-0", "hasInstallScript": true, "license": "MIT", "dependencies": { "@dotlottie/react-player": "^1.6.3", + "@expensify/react-native-live-markdown": "git+ssh://git@github.com/Expensify/react-native-live-markdown.git#c316611781f19815caebfed5540e0faf2a274785", "@expo/metro-runtime": "~3.1.1", "@formatjs/intl-datetimeformat": "^6.10.0", "@formatjs/intl-getcanonicallocales": "^2.2.0", @@ -3349,6 +3350,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@expensify/react-native-live-markdown": { + "version": "0.1.0", + "resolved": "git+ssh://git@github.com/Expensify/react-native-live-markdown.git#c316611781f19815caebfed5540e0faf2a274785", + "integrity": "sha512-yF3oaBhqWQonl12LPELYLsgfmqCsGg2bu15g/h8XzVX3f/nzfPtrWE/ax2lWEIpIjk4/+aEu/VGNKLnlehjTxQ==", + "license": "MIT", + "workspaces": [ + "example" + ], + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/@expo/bunyan": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@expo/bunyan/-/bunyan-4.0.0.tgz", @@ -44854,8 +44871,6 @@ }, "node_modules/react-native-flipper": { "version": "0.159.0", - "resolved": "https://gitpkg.now.sh/facebook/flipper/react-native/react-native-flipper?9cacc9b59402550eae866e0e81e5f0c2f8203e6b", - "integrity": "sha512-M784S/qPuN/HqjdvXg98HIDmfm0sF8mACc56YNg87nzEF90zKSKp0XyOE83SEW+UJX2Gq/rf9BvM2GZeXlrhnQ==", "dev": true, "license": "MIT", "peer": true, diff --git a/package.json b/package.json index bdbdb27dbf59..0c1105f978c6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.4.35-0", + "version": "1.4.36-0", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", @@ -59,6 +59,7 @@ }, "dependencies": { "@dotlottie/react-player": "^1.6.3", + "@expensify/react-native-live-markdown": "git+ssh://git@github.com/Expensify/react-native-live-markdown.git#c316611781f19815caebfed5540e0faf2a274785", "@expo/metro-runtime": "~3.1.1", "@formatjs/intl-datetimeformat": "^6.10.0", "@formatjs/intl-getcanonicallocales": "^2.2.0", diff --git a/patches/@react-navigation+stack+6.3.16+002+dontDetachScreen.patch b/patches/@react-navigation+stack+6.3.16+002+dontDetachScreen.patch index d64fc4fecf74..877521094cd4 100644 --- a/patches/@react-navigation+stack+6.3.16+002+dontDetachScreen.patch +++ b/patches/@react-navigation+stack+6.3.16+002+dontDetachScreen.patch @@ -43,7 +43,7 @@ index 7558eb3..b7bb75e 100644 }) : STATE_TRANSITIONING_OR_BELOW_TOP; } + -+ const isHomeScreenAndNotOnTop = route.name === 'Home' && isScreenActive !== STATE_ON_TOP; ++ const isHomeScreenAndNotOnTop = (route.name === 'BottomTabNavigator' || route.name === 'Settings_Root') && isScreenActive !== STATE_ON_TOP; + const { headerShown = true, diff --git a/patches/react-native-web+0.19.9+005+image-header-support.patch b/patches/react-native-web+0.19.9+005+image-header-support.patch deleted file mode 100644 index 4652e22662f0..000000000000 --- a/patches/react-native-web+0.19.9+005+image-header-support.patch +++ /dev/null @@ -1,200 +0,0 @@ -diff --git a/node_modules/react-native-web/dist/exports/Image/index.js b/node_modules/react-native-web/dist/exports/Image/index.js -index 95355d5..19109fc 100644 ---- a/node_modules/react-native-web/dist/exports/Image/index.js -+++ b/node_modules/react-native-web/dist/exports/Image/index.js -@@ -135,7 +135,22 @@ function resolveAssetUri(source) { - } - return uri; - } --var Image = /*#__PURE__*/React.forwardRef((props, ref) => { -+function raiseOnErrorEvent(uri, _ref) { -+ var onError = _ref.onError, -+ onLoadEnd = _ref.onLoadEnd; -+ if (onError) { -+ onError({ -+ nativeEvent: { -+ error: "Failed to load resource " + uri + " (404)" -+ } -+ }); -+ } -+ if (onLoadEnd) onLoadEnd(); -+} -+function hasSourceDiff(a, b) { -+ return a.uri !== b.uri || JSON.stringify(a.headers) !== JSON.stringify(b.headers); -+} -+var BaseImage = /*#__PURE__*/React.forwardRef((props, ref) => { - var ariaLabel = props['aria-label'], - blurRadius = props.blurRadius, - defaultSource = props.defaultSource, -@@ -236,16 +251,10 @@ var Image = /*#__PURE__*/React.forwardRef((props, ref) => { - } - }, function error() { - updateState(ERRORED); -- if (onError) { -- onError({ -- nativeEvent: { -- error: "Failed to load resource " + uri + " (404)" -- } -- }); -- } -- if (onLoadEnd) { -- onLoadEnd(); -- } -+ raiseOnErrorEvent(uri, { -+ onError, -+ onLoadEnd -+ }); - }); - } - function abortPendingRequest() { -@@ -277,10 +286,78 @@ var Image = /*#__PURE__*/React.forwardRef((props, ref) => { - suppressHydrationWarning: true - }), hiddenImage, createTintColorSVG(tintColor, filterRef.current)); - }); --Image.displayName = 'Image'; -+BaseImage.displayName = 'Image'; -+ -+/** -+ * This component handles specifically loading an image source with headers -+ * default source is never loaded using headers -+ */ -+var ImageWithHeaders = /*#__PURE__*/React.forwardRef((props, ref) => { -+ // $FlowIgnore: This component would only be rendered when `source` matches `ImageSource` -+ var nextSource = props.source; -+ var _React$useState3 = React.useState(''), -+ blobUri = _React$useState3[0], -+ setBlobUri = _React$useState3[1]; -+ var request = React.useRef({ -+ cancel: () => {}, -+ source: { -+ uri: '', -+ headers: {} -+ }, -+ promise: Promise.resolve('') -+ }); -+ var onError = props.onError, -+ onLoadStart = props.onLoadStart, -+ onLoadEnd = props.onLoadEnd; -+ React.useEffect(() => { -+ if (!hasSourceDiff(nextSource, request.current.source)) { -+ return; -+ } -+ -+ // When source changes we want to clean up any old/running requests -+ request.current.cancel(); -+ if (onLoadStart) { -+ onLoadStart(); -+ } -+ -+ // Store a ref for the current load request so we know what's the last loaded source, -+ // and so we can cancel it if a different source is passed through props -+ request.current = ImageLoader.loadWithHeaders(nextSource); -+ request.current.promise.then(uri => setBlobUri(uri)).catch(() => raiseOnErrorEvent(request.current.source.uri, { -+ onError, -+ onLoadEnd -+ })); -+ }, [nextSource, onLoadStart, onError, onLoadEnd]); -+ -+ // Cancel any request on unmount -+ React.useEffect(() => request.current.cancel, []); -+ var propsToPass = _objectSpread(_objectSpread({}, props), {}, { -+ // `onLoadStart` is called from the current component -+ // We skip passing it down to prevent BaseImage raising it a 2nd time -+ onLoadStart: undefined, -+ // Until the current component resolves the request (using headers) -+ // we skip forwarding the source so the base component doesn't attempt -+ // to load the original source -+ source: blobUri ? _objectSpread(_objectSpread({}, nextSource), {}, { -+ uri: blobUri -+ }) : undefined -+ }); -+ return /*#__PURE__*/React.createElement(BaseImage, _extends({ -+ ref: ref -+ }, propsToPass)); -+}); - - // $FlowIgnore: This is the correct type, but casting makes it unhappy since the variables aren't defined yet --var ImageWithStatics = Image; -+var ImageWithStatics = /*#__PURE__*/React.forwardRef((props, ref) => { -+ if (props.source && props.source.headers) { -+ return /*#__PURE__*/React.createElement(ImageWithHeaders, _extends({ -+ ref: ref -+ }, props)); -+ } -+ return /*#__PURE__*/React.createElement(BaseImage, _extends({ -+ ref: ref -+ }, props)); -+}); - ImageWithStatics.getSize = function (uri, success, failure) { - ImageLoader.getSize(uri, success, failure); - }; -diff --git a/node_modules/react-native-web/dist/modules/ImageLoader/index.js b/node_modules/react-native-web/dist/modules/ImageLoader/index.js -index bc06a87..e309394 100644 ---- a/node_modules/react-native-web/dist/modules/ImageLoader/index.js -+++ b/node_modules/react-native-web/dist/modules/ImageLoader/index.js -@@ -76,7 +76,7 @@ var ImageLoader = { - var image = requests["" + requestId]; - if (image) { - var naturalHeight = image.naturalHeight, -- naturalWidth = image.naturalWidth; -+ naturalWidth = image.naturalWidth; - if (naturalHeight && naturalWidth) { - success(naturalWidth, naturalHeight); - complete = true; -@@ -102,11 +102,19 @@ var ImageLoader = { - id += 1; - var image = new window.Image(); - image.onerror = onError; -- image.onload = e => { -+ image.onload = nativeEvent => { - // avoid blocking the main thread -- var onDecode = () => onLoad({ -- nativeEvent: e -- }); -+ var onDecode = () => { -+ // Append `source` to match RN's ImageLoadEvent interface -+ nativeEvent.source = { -+ uri: image.src, -+ width: image.naturalWidth, -+ height: image.naturalHeight -+ }; -+ onLoad({ -+ nativeEvent -+ }); -+ }; - if (typeof image.decode === 'function') { - // Safari currently throws exceptions when decoding svgs. - // We want to catch that error and allow the load handler -@@ -120,6 +128,32 @@ var ImageLoader = { - requests["" + id] = image; - return id; - }, -+ loadWithHeaders(source) { -+ var uri; -+ var abortController = new AbortController(); -+ var request = new Request(source.uri, { -+ headers: source.headers, -+ signal: abortController.signal -+ }); -+ request.headers.append('accept', 'image/*'); -+ var promise = fetch(request).then(response => response.blob()).then(blob => { -+ uri = URL.createObjectURL(blob); -+ return uri; -+ }).catch(error => { -+ if (error.name === 'AbortError') { -+ return ''; -+ } -+ throw error; -+ }); -+ return { -+ promise, -+ source, -+ cancel: () => { -+ abortController.abort(); -+ URL.revokeObjectURL(uri); -+ } -+ }; -+ }, - prefetch(uri) { - return new Promise((resolve, reject) => { - ImageLoader.load(uri, () => { diff --git a/src/App.js b/src/App.js index 3553900bbc7f..8045f4eb30ad 100644 --- a/src/App.js +++ b/src/App.js @@ -6,6 +6,7 @@ import Onyx from 'react-native-onyx'; import {PickerStateProvider} from 'react-native-picker-select'; import {SafeAreaProvider} from 'react-native-safe-area-context'; import '../wdyr'; +import ActiveWorkspaceContextProvider from './components/ActiveWorkspace/ActiveWorkspaceProvider'; import ColorSchemeWrapper from './components/ColorSchemeWrapper'; import ComposeProviders from './components/ComposeProviders'; import CustomStatusBarAndBackground from './components/CustomStatusBarAndBackground'; @@ -69,6 +70,7 @@ function App() { PickerStateProvider, EnvironmentProvider, CustomStatusBarAndBackgroundContextProvider, + ActiveWorkspaceContextProvider, ]} > diff --git a/src/CONST.ts b/src/CONST.ts index f434aa281866..b27923465a1f 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -46,6 +46,7 @@ const CONST = { IN: 'in', OUT: 'out', }, + BACKGROUND_IMAGE_TRANSITION_DURATION: 1000, ARROW_HIDE_DELAY: 3000, API_ATTACHMENT_VALIDATIONS: { @@ -490,6 +491,8 @@ const CONST = { // Use Environment.getEnvironmentURL to get the complete URL with port number DEV_NEW_EXPENSIFY_URL: 'https://dev.new.expensify.com:', OLDDOT_URLS: { + ADMIN_POLICIES_URL: 'admin_policies', + ADMIN_DOMAINS_URL: 'admin_domains', INBOX: 'inbox', }, @@ -1013,6 +1016,7 @@ const CONST = { 3: 100, }, }, + CENTRAL_PANE_ANIMATION_HEIGHT: 200, LHN_SKELETON_VIEW_ITEM_HEIGHT: 64, EXPENSIFY_PARTNER_NAME: 'expensify.com', EMAIL: { @@ -1331,6 +1335,7 @@ const CONST = { REIMBURSEMENT_MANUAL: 'reimburseManual', }, ID_FAKE: '_FAKE_', + EMPTY: 'EMPTY', }, CUSTOM_UNITS: { @@ -1481,6 +1486,10 @@ const CONST = { OTHER_INVISIBLE_CHARACTERS: /[\u3164]/g, REPORT_FIELD_TITLE: /{report:([a-zA-Z]+)}/g, + + PATH_WITHOUT_POLICY_ID: /\/w\/[a-zA-Z0-9]+(\/|$)/, + + POLICY_ID_FROM_PATH: /\/w\/([a-zA-Z0-9]+)(\/|$)/, }, PRONOUNS: { @@ -1490,7 +1499,7 @@ const CONST = { GUIDES_CALL_TASK_IDS: { CONCIERGE_DM: 'NewExpensifyConciergeDM', WORKSPACE_INITIAL: 'WorkspaceHome', - WORKSPACE_SETTINGS: 'WorkspaceGeneralSettings', + WORKSPACE_OVERVIEW: 'WorkspaceOverview', WORKSPACE_CARD: 'WorkspaceCorporateCards', WORKSPACE_REIMBURSE: 'WorkspaceReimburseReceipts', WORKSPACE_BILLS: 'WorkspacePayBills', @@ -3102,11 +3111,6 @@ const CONST = { CAROUSEL: 3, }, - BRICK_ROAD: { - GBR: 'GBR', - RBR: 'RBR', - }, - /** * Constants for types of violations. * Defined here because they need to be referenced by the type system to generate the @@ -3165,6 +3169,12 @@ const CONST = { MINI_CONTEXT_MENU_MAX_ITEMS: 4, + WORKSPACE_SWITCHER: { + NAME: 'Expensify', + SUBSCRIPT_ICON_SIZE: 8, + MINIMUM_WORKSPACES_TO_SHOW_SEARCH: 8, + }, + REPORT_FIELD_TITLE_FIELD_ID: 'text_title', } as const; diff --git a/src/Expensify.js b/src/Expensify.js index 12003968b284..dfb59a0f8848 100644 --- a/src/Expensify.js +++ b/src/Expensify.js @@ -62,7 +62,8 @@ const propTypes = { /** Whether a new update is available and ready to install. */ updateAvailable: PropTypes.bool, - /** Tells us if the sidebar has rendered */ + /** Tells us if the sidebar has rendered - TODO: We don't use it as temporary solution to fix not hidding splashscreen */ + // eslint-disable-next-line react/no-unused-prop-types isSidebarLoaded: PropTypes.bool, /** Information about a screen share call requested by a GuidesPlus agent */ @@ -83,6 +84,9 @@ const propTypes = { /** Whether we should display the notification alerting the user that focus mode has been auto-enabled */ focusModeNotification: PropTypes.bool, + /** Last visited path in the app */ + lastVisitedPath: PropTypes.string, + ...withLocalizePropTypes, }; @@ -97,6 +101,7 @@ const defaultProps = { isCheckingPublicRoom: true, updateRequired: false, focusModeNotification: false, + lastVisitedPath: undefined, }; const SplashScreenHiddenContext = React.createContext({}); @@ -107,6 +112,7 @@ function Expensify(props) { const [isOnyxMigrated, setIsOnyxMigrated] = useState(false); const [isSplashHidden, setIsSplashHidden] = useState(false); const [hasAttemptedToOpenPublicRoom, setAttemptedToOpenPublicRoom] = useState(false); + const [initialUrl, setInitialUrl] = useState(null); useEffect(() => { if (props.isCheckingPublicRoom) { @@ -125,7 +131,7 @@ function Expensify(props) { [isSplashHidden], ); - const shouldInit = isNavigationReady && (!isAuthenticated || props.isSidebarLoaded) && hasAttemptedToOpenPublicRoom; + const shouldInit = isNavigationReady && hasAttemptedToOpenPublicRoom; const shouldHideSplash = shouldInit && !isSplashHidden; const initializeClient = () => { @@ -187,6 +193,7 @@ function Expensify(props) { // If the app is opened from a deep link, get the reportID (if exists) from the deep link and navigate to the chat report Linking.getInitialURL().then((url) => { + setInitialUrl(url); Report.openReportFromDeepLink(url, isAuthenticated); }); @@ -247,6 +254,8 @@ function Expensify(props) { )} @@ -286,6 +295,9 @@ export default compose( key: ONYXKEYS.FOCUS_MODE_NOTIFICATION, initWithStoredValues: false, }, + lastVisitedPath: { + key: ONYXKEYS.LAST_VISITED_PATH, + }, }), )(Expensify); diff --git a/src/NAVIGATORS.ts b/src/NAVIGATORS.ts index c68a950d3501..3bc9c5e57b9b 100644 --- a/src/NAVIGATORS.ts +++ b/src/NAVIGATORS.ts @@ -4,6 +4,7 @@ * */ export default { CENTRAL_PANE_NAVIGATOR: 'CentralPaneNavigator', + BOTTOM_TAB_NAVIGATOR: 'BottomTabNavigator', LEFT_MODAL_NAVIGATOR: 'LeftModalNavigator', RIGHT_MODAL_NAVIGATOR: 'RightModalNavigator', FULL_SCREEN_NAVIGATOR: 'FullScreenNavigator', diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 2867cb3905a2..7328fb2543ad 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -243,6 +243,9 @@ const ONYXKEYS = { // Max width supported for HTML element MAX_CANVAS_WIDTH: 'maxCanvasWidth', + // Stores last visited path + LAST_VISITED_PATH: 'lastVisitedPath', + // Stores the recently used report fields RECENTLY_USED_REPORT_FIELDS: 'recentlyUsedReportFields', @@ -447,6 +450,7 @@ type OnyxValues = { [ONYXKEYS.MAX_CANVAS_AREA]: number; [ONYXKEYS.MAX_CANVAS_HEIGHT]: number; [ONYXKEYS.MAX_CANVAS_WIDTH]: number; + [ONYXKEYS.LAST_VISITED_PATH]: string | undefined; [ONYXKEYS.RECENTLY_USED_REPORT_FIELDS]: OnyxTypes.RecentlyUsedReportFields; [ONYXKEYS.UPDATE_REQUIRED]: boolean; @@ -454,7 +458,7 @@ type OnyxValues = { [ONYXKEYS.COLLECTION.DOWNLOAD]: OnyxTypes.Download; [ONYXKEYS.COLLECTION.POLICY]: OnyxTypes.Policy; [ONYXKEYS.COLLECTION.POLICY_DRAFTS]: OnyxTypes.Policy; - [ONYXKEYS.COLLECTION.POLICY_CATEGORIES]: OnyxTypes.PolicyCategory; + [ONYXKEYS.COLLECTION.POLICY_CATEGORIES]: OnyxTypes.PolicyCategories; [ONYXKEYS.COLLECTION.POLICY_TAGS]: OnyxTypes.PolicyTags; [ONYXKEYS.COLLECTION.POLICY_MEMBERS]: OnyxTypes.PolicyMembers; [ONYXKEYS.COLLECTION.POLICY_MEMBERS_DRAFTS]: OnyxTypes.PolicyMember; @@ -475,6 +479,7 @@ type OnyxValues = { [ONYXKEYS.COLLECTION.SECURITY_GROUP]: OnyxTypes.SecurityGroup; [ONYXKEYS.COLLECTION.TRANSACTION]: OnyxTypes.Transaction; [ONYXKEYS.COLLECTION.TRANSACTION_DRAFT]: OnyxTypes.Transaction; + [ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS]: OnyxTypes.TransactionViolations; [ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS]: OnyxTypes.RecentlyUsedTags; [ONYXKEYS.COLLECTION.SELECTED_TAB]: string; [ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS]: OnyxTypes.TransactionViolation[]; @@ -484,12 +489,12 @@ type OnyxValues = { // Forms [ONYXKEYS.FORMS.ADD_DEBIT_CARD_FORM]: OnyxTypes.AddDebitCardForm; [ONYXKEYS.FORMS.ADD_DEBIT_CARD_FORM_DRAFT]: OnyxTypes.AddDebitCardForm; - [ONYXKEYS.FORMS.WORKSPACE_SETTINGS_FORM]: OnyxTypes.Form; - [ONYXKEYS.FORMS.WORKSPACE_SETTINGS_FORM_DRAFT]: OnyxTypes.Form; + [ONYXKEYS.FORMS.WORKSPACE_SETTINGS_FORM]: OnyxTypes.WorkspaceSettingsForm; + [ONYXKEYS.FORMS.WORKSPACE_SETTINGS_FORM_DRAFT]: OnyxTypes.WorkspaceSettingsForm; [ONYXKEYS.FORMS.WORKSPACE_RATE_AND_UNIT_FORM]: OnyxTypes.Form; [ONYXKEYS.FORMS.WORKSPACE_RATE_AND_UNIT_FORM_DRAFT]: OnyxTypes.Form; - [ONYXKEYS.FORMS.CLOSE_ACCOUNT_FORM]: OnyxTypes.Form; - [ONYXKEYS.FORMS.CLOSE_ACCOUNT_FORM_DRAFT]: OnyxTypes.Form; + [ONYXKEYS.FORMS.CLOSE_ACCOUNT_FORM]: OnyxTypes.CloseAccountForm; + [ONYXKEYS.FORMS.CLOSE_ACCOUNT_FORM_DRAFT]: OnyxTypes.CloseAccountForm; [ONYXKEYS.FORMS.PROFILE_SETTINGS_FORM]: OnyxTypes.Form; [ONYXKEYS.FORMS.PROFILE_SETTINGS_FORM_DRAFT]: OnyxTypes.Form; [ONYXKEYS.FORMS.DISPLAY_NAME_FORM]: OnyxTypes.DisplayNameForm; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 9c4375b84ab6..a84dc9c8f9ae 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -12,7 +12,13 @@ function getUrlWithBackToParam(url: TUrl, backTo?: string): } const ROUTES = { - HOME: '', + // If the user opens this route, we'll redirect them to the path saved in the last visited path or to the home page if the last visited path is empty. + ROOT: '', + + // This route renders the list of reports. + HOME: 'home', + + ALL_SETTINGS: 'all-settings', // This is a utility route used to go to the user's concierge chat, or the sign-in page if the user's not authenticated CONCIERGE: 'concierge', @@ -59,7 +65,7 @@ const ROUTES = { route: 'bank-account/:stepToOpen?', getRoute: (stepToOpen = '', policyID = '', backTo?: string) => getUrlWithBackToParam(`bank-account/${stepToOpen}?policyID=${policyID}`, backTo), }, - + WORKSPACE_SWITCHER: 'workspace-switcher', SETTINGS: 'settings', SETTINGS_PROFILE: 'settings/profile', SETTINGS_SHARE_CODE: 'settings/shareCode', @@ -439,9 +445,17 @@ const ROUTES = { route: 'workspace/:policyID/invite-message', getRoute: (policyID: string) => `workspace/${policyID}/invite-message` as const, }, - WORKSPACE_SETTINGS: { - route: 'workspace/:policyID/settings', - getRoute: (policyID: string) => `workspace/${policyID}/settings` as const, + WORKSPACE_OVERVIEW: { + route: 'workspace/:policyID/overview', + getRoute: (policyID: string) => `workspace/${policyID}/overview` as const, + }, + WORKSPACE_OVERVIEW_CURRENCY: { + route: 'workspace/:policyID/overview/currency', + getRoute: (policyID: string) => `workspace/${policyID}/overview/currency` as const, + }, + WORKSPACE_OVERVIEW_NAME: { + route: 'workspace/:policyID/overview/name', + getRoute: (policyID: string) => `workspace/${policyID}/overview/name` as const, }, WORKSPACE_AVATAR: { route: 'workspace/:policyID/avatar', diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 2bf40caede57..96b284dbea2f 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -12,6 +12,7 @@ const PROTECTED_SCREENS = { const SCREENS = { ...PROTECTED_SCREENS, + ALL_SETTINGS: 'AllSettings', REPORT: 'Report', PROFILE_AVATAR: 'ProfileAvatar', WORKSPACE_AVATAR: 'WorkspaceAvatar', @@ -20,6 +21,7 @@ const SCREENS = { TRANSITION_BETWEEN_APPS: 'TransitionBetweenApps', VALIDATE_LOGIN: 'ValidateLogin', UNLINK_LOGIN: 'UnlinkLogin', + SETTINGS_CENTRAL_PANE: 'SettingsCentralPane', SETTINGS: { ROOT: 'Settings_Root', SHARE_CODE: 'Settings_Share_Code', @@ -86,6 +88,10 @@ const SCREENS = { }, LEFT_MODAL: { SEARCH: 'Search', + WORKSPACE_SWITCHER: 'WorkspaceSwitcher', + }, + WORKSPACE_SWITCHER: { + ROOT: 'WorkspaceSwitcher_Root', }, RIGHT_MODAL: { SETTINGS: 'Settings', @@ -194,7 +200,7 @@ const SCREENS = { WORKSPACE: { INITIAL: 'Workspace_Initial', - SETTINGS: 'Workspace_Settings', + OVERVIEW: 'Workspace_Overview', CARD: 'Workspace_Card', REIMBURSE: 'Workspace_Reimburse', RATE_AND_UNIT: 'Workspace_RateAndUnit', @@ -204,7 +210,8 @@ const SCREENS = { MEMBERS: 'Workspace_Members', INVITE: 'Workspace_Invite', INVITE_MESSAGE: 'Workspace_Invite_Message', - CURRENCY: 'Workspace_Settings_Currency', + CURRENCY: 'Workspace_Overview_Currency', + NAME: 'Workspace_Overview_Name', }, EDIT_REQUEST: { diff --git a/src/components/ActiveWorkspace/ActiveWorkspaceContext.tsx b/src/components/ActiveWorkspace/ActiveWorkspaceContext.tsx new file mode 100644 index 000000000000..466f0f492c8e --- /dev/null +++ b/src/components/ActiveWorkspace/ActiveWorkspaceContext.tsx @@ -0,0 +1,11 @@ +import {createContext} from 'react'; + +type ActiveWorkspaceContextType = { + activeWorkspaceID?: string; + setActiveWorkspaceID: (activeWorkspaceID?: string) => void; +}; + +const ActiveWorkspaceContext = createContext({activeWorkspaceID: undefined, setActiveWorkspaceID: () => undefined}); + +export default ActiveWorkspaceContext; +export {type ActiveWorkspaceContextType}; diff --git a/src/components/ActiveWorkspace/ActiveWorkspaceProvider.tsx b/src/components/ActiveWorkspace/ActiveWorkspaceProvider.tsx new file mode 100644 index 000000000000..884b9a2a2d95 --- /dev/null +++ b/src/components/ActiveWorkspace/ActiveWorkspaceProvider.tsx @@ -0,0 +1,19 @@ +import React, {useMemo, useState} from 'react'; +import type ChildrenProps from '@src/types/utils/ChildrenProps'; +import ActiveWorkspaceContext from './ActiveWorkspaceContext'; + +function ActiveWorkspaceContextProvider({children}: ChildrenProps) { + const [activeWorkspaceID, setActiveWorkspaceID] = useState(undefined); + + const value = useMemo( + () => ({ + activeWorkspaceID, + setActiveWorkspaceID, + }), + [activeWorkspaceID], + ); + + return {children}; +} + +export default ActiveWorkspaceContextProvider; diff --git a/src/components/AvatarCropModal/AvatarCropModal.tsx b/src/components/AvatarCropModal/AvatarCropModal.tsx index 3ac2e3e3d729..dd6d41f4b227 100644 --- a/src/components/AvatarCropModal/AvatarCropModal.tsx +++ b/src/components/AvatarCropModal/AvatarCropModal.tsx @@ -341,6 +341,7 @@ function AvatarCropModal({imageUri = '', imageName = '', imageType = '', onClose isVisible={isVisible} type={CONST.MODAL.MODAL_TYPE.RIGHT_DOCKED} onModalHide={resetState} + shouldUseCustomBackdrop > {}, onImageRemoved: () => {}, style: [], + disabledStyle: [], DefaultAvatar: () => {}, isUsingDefaultAvatar: false, size: CONST.AVATAR_SIZE.DEFAULT, @@ -118,6 +121,7 @@ const defaultProps = { headerTitle: '', previewSource: '', originalFileName: '', + disabled: false, onViewPhotoPress: undefined, anchorAlignment: { horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, @@ -129,6 +133,7 @@ function AvatarWithImagePicker({ isFocused, DefaultAvatar, style, + disabledStyle, pendingAction, errors, errorRowStyles, @@ -142,14 +147,16 @@ function AvatarWithImagePicker({ originalFileName, isUsingDefaultAvatar, onImageRemoved, - anchorPosition, - anchorAlignment, onImageSelected, editorMaskImage, + avatarStyle, + disabled, onViewPhotoPress, }) { const theme = useTheme(); const styles = useThemeStyles(); + const {windowWidth} = useWindowDimensions(); + const [popoverPosition, setPopoverPosition] = useState({horizontal: 0, vertical: 0}); const [isMenuVisible, setIsMenuVisible] = useState(false); const [errorData, setErrorData] = useState({ validationError: null, @@ -291,28 +298,50 @@ function AvatarWithImagePicker({ return menuItems; }; + useEffect(() => { + if (!anchorRef.current) { + return; + } + + if (!isMenuVisible) { + return; + } + + anchorRef.current.measureInWindow((x, y, width, height) => { + setPopoverPosition({ + horizontal: x + (width - variables.photoUploadPopoverWidth) / 2, + vertical: y + height + variables.spacing2, + }); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isMenuVisible, windowWidth]); + return ( - + - + setIsMenuVisible((prev) => !prev)} accessibilityRole={CONST.ACCESSIBILITY_ROLE.IMAGEBUTTON} accessibilityLabel={translate('avatarWithImagePicker.editImage')} - disabled={isAvatarCropModalOpen} + disabled={isAvatarCropModalOpen || disabled} + disabledStyle={disabledStyle} ref={anchorRef} > {source ? ( )} - - - + {!disabled && ( + + + + )} @@ -376,10 +407,10 @@ function AvatarWithImagePicker({ } }} menuItems={menuItems} - anchorPosition={anchorPosition} + anchorPosition={popoverPosition} + anchorAlignment={{horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP}} withoutOverlay anchorRef={anchorRef} - anchorAlignment={anchorAlignment} /> ); }} diff --git a/src/components/BlockingViews/ForceFullScreenView/index.native.tsx b/src/components/BlockingViews/ForceFullScreenView/index.native.tsx new file mode 100644 index 000000000000..296e7c26a9bc --- /dev/null +++ b/src/components/BlockingViews/ForceFullScreenView/index.native.tsx @@ -0,0 +1,9 @@ +import type ForceFullScreenViewProps from './types'; + +function ForceFullScreenView({children}: ForceFullScreenViewProps) { + return children; +} + +ForceFullScreenView.displayName = 'ForceFullScreenView'; + +export default ForceFullScreenView; diff --git a/src/components/BlockingViews/ForceFullScreenView/index.tsx b/src/components/BlockingViews/ForceFullScreenView/index.tsx new file mode 100644 index 000000000000..8a02028168fa --- /dev/null +++ b/src/components/BlockingViews/ForceFullScreenView/index.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import {View} from 'react-native'; +import useThemeStyles from '@hooks/useThemeStyles'; +import type ForceFullScreenViewProps from './types'; + +function ForceFullScreenView({children, shouldForceFullScreen = false}: ForceFullScreenViewProps) { + const styles = useThemeStyles(); + + if (shouldForceFullScreen) { + return {children}; + } + + return children; +} + +ForceFullScreenView.displayName = 'ForceFullScreenView'; + +export default ForceFullScreenView; diff --git a/src/components/BlockingViews/ForceFullScreenView/types.ts b/src/components/BlockingViews/ForceFullScreenView/types.ts new file mode 100644 index 000000000000..b03e6d5900d7 --- /dev/null +++ b/src/components/BlockingViews/ForceFullScreenView/types.ts @@ -0,0 +1,7 @@ +import type ChildrenProps from '@src/types/utils/ChildrenProps'; + +type ForceFullScreenViewProps = ChildrenProps & { + shouldForceFullScreen?: boolean; +}; + +export default ForceFullScreenViewProps; diff --git a/src/components/BlockingViews/FullPageNotFoundView.tsx b/src/components/BlockingViews/FullPageNotFoundView.tsx index 807029addf5e..8cabf7dce494 100644 --- a/src/components/BlockingViews/FullPageNotFoundView.tsx +++ b/src/components/BlockingViews/FullPageNotFoundView.tsx @@ -9,6 +9,7 @@ import variables from '@styles/variables'; import type {TranslationPaths} from '@src/languages/types'; import ROUTES from '@src/ROUTES'; import BlockingView from './BlockingView'; +import ForceFullScreenView from './ForceFullScreenView'; type FullPageNotFoundViewProps = { /** Child elements */ @@ -37,6 +38,9 @@ type FullPageNotFoundViewProps = { /** Function to call when pressing the navigation link */ onLinkPress?: () => void; + + /** Whether we should force the full page view */ + shouldForceFullScreen?: boolean; }; // eslint-disable-next-line rulesdir/no-negated-variables @@ -50,13 +54,14 @@ function FullPageNotFoundView({ shouldShowLink = true, shouldShowBackButton = true, onLinkPress = () => Navigation.dismissModal(), + shouldForceFullScreen = false, }: FullPageNotFoundViewProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); if (shouldShow) { return ( - <> + - + ); } diff --git a/src/components/Composer/index.native.tsx b/src/components/Composer/index.native.tsx index c7b020a5c6dd..a3c037211d4c 100644 --- a/src/components/Composer/index.native.tsx +++ b/src/components/Composer/index.native.tsx @@ -1,9 +1,10 @@ +import {MarkdownTextInput} from '@expensify/react-native-live-markdown'; import type {ForwardedRef} from 'react'; import React, {useCallback, useEffect, useMemo, useRef} from 'react'; import type {TextInput} from 'react-native'; import {StyleSheet} from 'react-native'; import type {AnimatedTextInputRef} from '@components/RNTextInput'; -import RNTextInput from '@components/RNTextInput'; +import useMarkdownStyle from '@hooks/useMarkdownStyle'; import useResetComposerFocus from '@hooks/useResetComposerFocus'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; @@ -33,6 +34,7 @@ function Composer( const textInput = useRef(null); const {isFocused, shouldResetFocus} = useResetComposerFocus(textInput); const theme = useTheme(); + const markdownStyle = useMarkdownStyle(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -66,7 +68,7 @@ function Composer( const composerStyle = useMemo(() => StyleSheet.flatten(style), [style]); return ( - (null); const textInput = useRef(null); const [numberOfLines, setNumberOfLines] = useState(numberOfLinesProp); @@ -152,41 +150,6 @@ function Composer( } }; - /** - * Set pasted text to clipboard - */ - const paste = useCallback((text?: string) => { - try { - document.execCommand('insertText', false, text); - // Pointer will go out of sight when a large paragraph is pasted on the web. Refocusing the input keeps the cursor in view. - textInput.current?.blur(); - textInput.current?.focus(); - // eslint-disable-next-line no-empty - } catch (e) {} - }, []); - - /** - * Manually place the pasted HTML into Composer - */ - const handlePastedHTML = useCallback( - (html: string) => { - const parser = new ExpensiMark(); - paste(parser.htmlToMarkdown(html)); - }, - [paste], - ); - - /** - * Paste the plaintext content into Composer. - */ - const handlePastePlainText = useCallback( - (event: ClipboardEvent) => { - const plainText = event.clipboardData?.getData('text/plain'); - paste(plainText); - }, - [paste], - ); - /** * Check the paste event for an attachment, parse the data and call onPasteFile from props with the selected file, * Otherwise, convert pasted HTML to Markdown and set it on the composer. @@ -197,7 +160,7 @@ function Composer( const isFocused = textInput.current?.isFocused(); if (!(isVisible || isFocused)) { - return; + return true; } if (textInput.current !== event.target) { @@ -207,7 +170,7 @@ function Composer( // If it did originate in another input, we return early to prevent the composer from handling the paste const isTargetInput = eventTarget?.nodeName === 'INPUT' || eventTarget?.nodeName === 'TEXTAREA' || eventTarget?.contentEditable === 'true'; if (isTargetInput) { - return; + return true; } textInput.current?.focus(); @@ -218,13 +181,12 @@ function Composer( const TEXT_HTML = 'text/html'; const clipboardDataHtml = event.clipboardData?.getData(TEXT_HTML) ?? ''; - const clipboardDataTypesHtml = event.clipboardData?.types.includes(TEXT_HTML) ?? false; // If paste contains files, then trigger file management if (event.clipboardData?.files.length && event.clipboardData.files.length > 0) { // Prevent the default so we do not post the file name into the text box - onPasteFile(event.clipboardData?.files[0]); - return; + onPasteFile(event.clipboardData.files[0]); + return true; } // If paste contains base64 image @@ -237,7 +199,7 @@ function Composer( const src = embeddedImages[0].src; const file = FileUtils.base64ToFile(src, 'image.png'); onPasteFile(file); - return; + return true; } } @@ -256,32 +218,13 @@ function Composer( const file = new File([blob], 'image.jpg', {type: 'image/jpeg'}); onPasteFile(file); }); - return; + return true; } } } - - // If paste contains HTML - if (clipboardDataTypesHtml) { - const pastedHTML = clipboardDataHtml; - - const domparser = new DOMParser(); - const embeddedImages = domparser.parseFromString(pastedHTML, TEXT_HTML).images; - - // Exclude parsing img tags in the HTML, as fetching the image via fetch triggers a connect-src Content-Security-Policy error. - if (embeddedImages.length > 0 && embeddedImages[0].src) { - // If HTML has emoji, then treat this as plain text. - if (embeddedImages[0].dataset && embeddedImages[0].dataset.stringifyType === 'emoji') { - handlePastePlainText(event); - return; - } - } - handlePastedHTML(pastedHTML); - return; - } - handlePastePlainText(event); + return false; }, - [onPasteFile, handlePastedHTML, checkComposerVisibility, handlePastePlainText], + [onPasteFile, checkComposerVisibility], ); /** @@ -313,27 +256,18 @@ function Composer( updateNumberOfLines(); }, [updateNumberOfLines]); - useEffect(() => { - // we need to handle listeners on navigation focus/blur as Composer is not unmounting - // when navigating away to different report - const unsubscribeFocus = navigation.addListener('focus', () => document.addEventListener('paste', handlePaste)); - const unsubscribeBlur = navigation.addListener('blur', () => document.removeEventListener('paste', handlePaste)); + useHtmlPaste(textInput, handlePaste, true); + useEffect(() => { if (typeof ref === 'function') { ref(textInput.current); } - if (textInput.current) { - document.addEventListener('paste', handlePaste); - } - return () => { - if (!isReportActionCompose) { - ReportActionComposeFocusManager.clear(); + if (isReportActionCompose) { + return; } - unsubscribeFocus(); - unsubscribeBlur(); - document.removeEventListener('paste', handlePaste); + ReportActionComposeFocusManager.clear(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/src/components/ContextMenuItem.tsx b/src/components/ContextMenuItem.tsx index 4e9bd22e004c..d292f80d135e 100644 --- a/src/components/ContextMenuItem.tsx +++ b/src/components/ContextMenuItem.tsx @@ -38,6 +38,9 @@ type ContextMenuItemProps = { /** Whether the menu item is focused or not */ isFocused?: boolean; + + /** Whether the width should be limited */ + shouldLimitWidth?: boolean; }; type ContextMenuItemHandle = { @@ -45,7 +48,7 @@ type ContextMenuItemHandle = { }; function ContextMenuItem( - {onPress, successIcon, successText = '', icon, text, isMini = false, description = '', isAnonymousAction = false, isFocused = false}: ContextMenuItemProps, + {onPress, successIcon, successText = '', icon, text, isMini = false, description = '', isAnonymousAction = false, isFocused = false, shouldLimitWidth = true}: ContextMenuItemProps, ref: ForwardedRef, ) { const styles = useThemeStyles(); @@ -94,7 +97,7 @@ function ContextMenuItem( success={!isThrottledButtonActive} description={description} descriptionTextStyle={styles.breakWord} - style={StyleUtils.getContextMenuItemStyles(windowWidth)} + style={shouldLimitWidth && StyleUtils.getContextMenuItemStyles(windowWidth)} isAnonymousAction={isAnonymousAction} focused={isFocused} interactive={isThrottledButtonActive} diff --git a/src/components/DotIndicatorMessage.tsx b/src/components/DotIndicatorMessage.tsx index d18704fdfb05..d2143f5b48da 100644 --- a/src/components/DotIndicatorMessage.tsx +++ b/src/components/DotIndicatorMessage.tsx @@ -8,13 +8,12 @@ import useThemeStyles from '@hooks/useThemeStyles'; import fileDownload from '@libs/fileDownload'; import * as Localize from '@libs/Localize'; import CONST from '@src/CONST'; +import type {ReceiptError} from '@src/types/onyx/Transaction'; import Icon from './Icon'; import * as Expensicons from './Icon/Expensicons'; import {PressableWithoutFeedback} from './Pressable'; import Text from './Text'; -type ReceiptError = {error?: string; source: string; filename: string}; - type DotIndicatorMessageProps = { /** * In most cases this should just be errors from onxyData @@ -23,7 +22,7 @@ type DotIndicatorMessageProps = { * timestamp: 'message', * } */ - messages: Record; + messages: Record; /** The type of message, 'error' shows a red dot, 'success' shows a green dot */ type: 'error' | 'success'; diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.js b/src/components/EmojiPicker/EmojiPickerMenu/index.js index a723eed446a4..cbf55b31c74d 100755 --- a/src/components/EmojiPicker/EmojiPickerMenu/index.js +++ b/src/components/EmojiPicker/EmojiPickerMenu/index.js @@ -7,6 +7,7 @@ import _ from 'underscore'; import EmojiPickerMenuItem from '@components/EmojiPicker/EmojiPickerMenuItem'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; +import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; import useLocalize from '@hooks/useLocalize'; import useSingleExecution from '@hooks/useSingleExecution'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -52,6 +53,7 @@ function EmojiPickerMenu({forwardedRef, onEmojiSelected}) { preferredSkinTone, listStyle, emojiListRef, + spacersIndexes, } = useEmojiPickerMenu(); // Ref for the emoji search input @@ -61,22 +63,11 @@ function EmojiPickerMenu({forwardedRef, onEmojiSelected}) { // prevent auto focus when open picker for mobile device const shouldFocusInputOnScreenFocus = canFocusInputOnScreenFocus(); - const [highlightedIndex, setHighlightedIndex] = useState(-1); const [arePointerEventsDisabled, setArePointerEventsDisabled] = useState(false); - const [selection, setSelection] = useState({start: 0, end: 0}); const [isFocused, setIsFocused] = useState(false); const [isUsingKeyboardMovement, setIsUsingKeyboardMovement] = useState(false); + const [highlightEmoji, setHighlightEmoji] = useState(false); const [highlightFirstEmoji, setHighlightFirstEmoji] = useState(false); - const firstNonHeaderIndex = useMemo(() => _.findIndex(filteredEmojis, (item) => !item.spacer && !item.header), [filteredEmojis]); - - /** - * On text input selection change - * - * @param {Event} event - */ - const onSelectionChange = useCallback((event) => { - setSelection(event.nativeEvent.selection); - }, []); const mouseMoveHandler = useCallback(() => { if (!arePointerEventsDisabled) { @@ -85,15 +76,39 @@ function EmojiPickerMenu({forwardedRef, onEmojiSelected}) { setArePointerEventsDisabled(false); }, [arePointerEventsDisabled]); - /** - * Focuses the search Input and has the text selected - */ - function focusInputWithTextSelect() { - if (!searchInputRef.current) { - return; - } - searchInputRef.current.focus(); - } + const onFocusedIndexChange = useCallback( + (newIndex) => { + if (filteredEmojis.length === 0) { + return; + } + + if (highlightFirstEmoji) { + setHighlightFirstEmoji(false); + } + + if (!isUsingKeyboardMovement) { + setIsUsingKeyboardMovement(true); + } + + // If the input is not focused and the new index is out of range, focus the input + if (newIndex < 0 && !searchInputRef.current.isFocused()) { + searchInputRef.current.focus(); + } + }, + [filteredEmojis.length, highlightFirstEmoji, isUsingKeyboardMovement], + ); + + const disabledIndexes = useMemo(() => (isListFiltered ? [] : [...headerIndices, ...spacersIndexes]), [headerIndices, isListFiltered, spacersIndexes]); + + const [focusedIndex, setFocusedIndex] = useArrowKeyFocusManager({ + maxIndex: filteredEmojis.length - 1, + // Spacers indexes need to be disabled so that the arrow keys don't focus them. All headers are hidden when list is filtered + disabledIndexes, + itemsPerRow: CONST.EMOJI_NUM_PER_ROW, + initialFocusedIndex: -1, + disableCyclicTraversal: true, + onFocusedIndexChange, + }); const filterEmojis = _.throttle((searchTerm) => { const [normalizedSearchTerm, newFilteredEmojiList] = suggestEmojis(searchTerm); @@ -105,119 +120,17 @@ function EmojiPickerMenu({forwardedRef, onEmojiSelected}) { // There are no headers when searching, so we need to re-make them sticky when there is no search term setFilteredEmojis(allEmojis); setHeaderIndices(headerRowIndices); - setHighlightedIndex(-1); - setHighlightFirstEmoji(false); + setFocusedIndex(-1); + setHighlightEmoji(false); return; } // Remove sticky header indices. There are no headers while searching and we don't want to make emojis sticky setFilteredEmojis(newFilteredEmojiList); setHeaderIndices([]); - setHighlightedIndex(0); setHighlightFirstEmoji(true); + setIsUsingKeyboardMovement(false); }, throttleTime); - /** - * Highlights emojis adjacent to the currently highlighted emoji depending on the arrowKey - * @param {String} arrowKey - */ - const highlightAdjacentEmoji = useCallback( - (arrowKey) => { - if (filteredEmojis.length === 0) { - return; - } - - // Arrow Down and Arrow Right enable arrow navigation when search is focused - if (searchInputRef.current && searchInputRef.current.isFocused()) { - if (arrowKey !== 'ArrowDown' && arrowKey !== 'ArrowRight') { - return; - } - - if (arrowKey === 'ArrowRight' && !(searchInputRef.current.value.length === selection.start && selection.start === selection.end)) { - return; - } - - // Blur the input, change the highlight type to keyboard, and disable pointer events - searchInputRef.current.blur(); - setArePointerEventsDisabled(true); - setIsUsingKeyboardMovement(true); - setHighlightFirstEmoji(false); - - // We only want to hightlight the Emoji if none was highlighted already - // If we already have a highlighted Emoji, lets just skip the first navigation - if (highlightedIndex !== -1) { - return; - } - } - - // If nothing is highlighted and an arrow key is pressed - // select the first emoji, apply keyboard movement styles, and disable pointer events - if (highlightedIndex === -1) { - setHighlightedIndex(firstNonHeaderIndex); - setArePointerEventsDisabled(true); - setIsUsingKeyboardMovement(true); - return; - } - - let newIndex = highlightedIndex; - const move = (steps, boundsCheck, onBoundReached = () => {}) => { - if (boundsCheck()) { - onBoundReached(); - return; - } - - // Move in the prescribed direction until we reach an element that isn't a header - const isHeader = (e) => e.header || e.spacer; - do { - newIndex += steps; - if (newIndex < 0) { - break; - } - } while (isHeader(filteredEmojis[newIndex])); - }; - - switch (arrowKey) { - case 'ArrowDown': - move(CONST.EMOJI_NUM_PER_ROW, () => highlightedIndex + CONST.EMOJI_NUM_PER_ROW > filteredEmojis.length - 1); - break; - case 'ArrowLeft': - move( - -1, - () => highlightedIndex - 1 < firstNonHeaderIndex, - () => { - // Reaching start of the list, arrow left set the focus to searchInput. - focusInputWithTextSelect(); - newIndex = -1; - }, - ); - break; - case 'ArrowRight': - move(1, () => highlightedIndex + 1 > filteredEmojis.length - 1); - break; - case 'ArrowUp': - move( - -CONST.EMOJI_NUM_PER_ROW, - () => highlightedIndex - CONST.EMOJI_NUM_PER_ROW < firstNonHeaderIndex, - () => { - // Reaching start of the list, arrow up set the focus to searchInput. - focusInputWithTextSelect(); - newIndex = -1; - }, - ); - break; - default: - break; - } - - // Actually highlight the new emoji, apply keyboard movement styles, and disable pointer events - if (newIndex !== highlightedIndex) { - setHighlightedIndex(newIndex); - setArePointerEventsDisabled(true); - setIsUsingKeyboardMovement(true); - } - }, - [filteredEmojis, firstNonHeaderIndex, highlightedIndex, selection.end, selection.start], - ); - const keyDownHandler = useCallback( (keyBoardEvent) => { if (keyBoardEvent.key.startsWith('Arrow')) { @@ -225,14 +138,17 @@ function EmojiPickerMenu({forwardedRef, onEmojiSelected}) { keyBoardEvent.preventDefault(); } - // Move the highlight when arrow keys are pressed - highlightAdjacentEmoji(keyBoardEvent.key); return; } // Select the currently highlighted emoji if enter is pressed - if (!isEnterWhileComposition(keyBoardEvent) && keyBoardEvent.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey && highlightedIndex !== -1) { - const item = filteredEmojis[highlightedIndex]; + if (!isEnterWhileComposition(keyBoardEvent) && keyBoardEvent.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey) { + let indexToSelect = focusedIndex; + if (highlightFirstEmoji) { + indexToSelect = 0; + } + + const item = filteredEmojis[indexToSelect]; if (!item) { return; } @@ -250,7 +166,6 @@ function EmojiPickerMenu({forwardedRef, onEmojiSelected}) { // interfering with the input behaviour. if (keyBoardEvent.key === 'Tab' || keyBoardEvent.key === 'Enter' || (keyBoardEvent.key === 'Shift' && searchInputRef.current && !searchInputRef.current.isFocused())) { setIsUsingKeyboardMovement(true); - return; } // We allow typing in the search box if any key is pressed apart from Arrow keys. @@ -258,7 +173,7 @@ function EmojiPickerMenu({forwardedRef, onEmojiSelected}) { searchInputRef.current.focus(); } }, - [filteredEmojis, highlightAdjacentEmoji, highlightedIndex, isFocused, onEmojiSelected, preferredSkinTone], + [filteredEmojis, focusedIndex, highlightFirstEmoji, isFocused, onEmojiSelected, preferredSkinTone], ); /** @@ -343,13 +258,15 @@ function EmojiPickerMenu({forwardedRef, onEmojiSelected}) { const emojiCode = types && types[preferredSkinTone] ? types[preferredSkinTone] : code; - const isEmojiFocused = index === highlightedIndex && isUsingKeyboardMovement; - const shouldEmojiBeHighlighted = index === highlightedIndex && highlightFirstEmoji; + const isEmojiFocused = index === focusedIndex && isUsingKeyboardMovement; + const shouldEmojiBeHighlighted = index === focusedIndex && highlightEmoji; + const shouldFirstEmojiBeHighlighted = index === 0 && highlightFirstEmoji; return ( onEmojiSelected(emoji, item))} onHoverIn={() => { + setHighlightEmoji(false); setHighlightFirstEmoji(false); if (!isUsingKeyboardMovement) { return; @@ -357,18 +274,26 @@ function EmojiPickerMenu({forwardedRef, onEmojiSelected}) { setIsUsingKeyboardMovement(false); }} emoji={emojiCode} - onFocus={() => setHighlightedIndex(index)} - onBlur={() => - // Only clear the highlighted index if the highlighted index is the same, - // meaning that the focus changed to an element that is not an emoji item. - setHighlightedIndex((prevState) => (prevState === index ? -1 : prevState)) - } + onFocus={() => setFocusedIndex(index)} isFocused={isEmojiFocused} - isHighlighted={shouldEmojiBeHighlighted} + isHighlighted={shouldFirstEmojiBeHighlighted || shouldEmojiBeHighlighted} /> ); }, - [preferredSkinTone, highlightedIndex, isUsingKeyboardMovement, highlightFirstEmoji, singleExecution, translate, onEmojiSelected, isSmallScreenWidth, windowWidth, styles], + [ + preferredSkinTone, + focusedIndex, + isUsingKeyboardMovement, + highlightEmoji, + highlightFirstEmoji, + singleExecution, + styles, + isSmallScreenWidth, + windowWidth, + translate, + onEmojiSelected, + setFocusedIndex, + ], ); return ( @@ -389,9 +314,8 @@ function EmojiPickerMenu({forwardedRef, onEmojiSelected}) { defaultValue="" ref={searchInputRef} autoFocus={shouldFocusInputOnScreenFocus} - onSelectionChange={onSelectionChange} onFocus={() => { - setHighlightedIndex(-1); + setFocusedIndex(-1); setIsFocused(true); setIsUsingKeyboardMovement(false); }} @@ -413,7 +337,7 @@ function EmojiPickerMenu({forwardedRef, onEmojiSelected}) { ref={emojiListRef} data={filteredEmojis} renderItem={renderItem} - extraData={[highlightedIndex, preferredSkinTone]} + extraData={[focusedIndex, preferredSkinTone]} stickyHeaderIndices={headerIndices} /> diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.native.js b/src/components/EmojiPicker/EmojiPickerMenu/index.native.js index 1463ce736699..27a49d360906 100644 --- a/src/components/EmojiPicker/EmojiPickerMenu/index.native.js +++ b/src/components/EmojiPicker/EmojiPickerMenu/index.native.js @@ -21,6 +21,7 @@ function EmojiPickerMenu({onEmojiSelected}) { const styles = useThemeStyles(); const {windowWidth, isSmallScreenWidth} = useWindowDimensions(); const {translate} = useLocalize(); + const {singleExecution} = useSingleExecution(); const { allEmojis, headerEmojis, @@ -35,7 +36,6 @@ function EmojiPickerMenu({onEmojiSelected}) { listStyle, emojiListRef, } = useEmojiPickerMenu(); - const {singleExecution} = useSingleExecution(); const StyleUtils = useStyleUtils(); /** @@ -73,7 +73,7 @@ function EmojiPickerMenu({onEmojiSelected}) { /** * Given an emoji item object, render a component based on its type. * Items with the code "SPACER" return nothing and are used to fill rows up to 8 - * so that the sticky headers function properly + * so that the sticky headers function properly. * * @param {Object} item * @returns {*} diff --git a/src/components/EmojiPicker/EmojiPickerMenu/useEmojiPickerMenu.js b/src/components/EmojiPicker/EmojiPickerMenu/useEmojiPickerMenu.js index 2d895193ec68..7caab5e6fb55 100644 --- a/src/components/EmojiPicker/EmojiPickerMenu/useEmojiPickerMenu.js +++ b/src/components/EmojiPicker/EmojiPickerMenu/useEmojiPickerMenu.js @@ -16,6 +16,7 @@ const useEmojiPickerMenu = () => { const allEmojis = useMemo(() => EmojiUtils.mergeEmojisWithFrequentlyUsedEmojis(emojis), [frequentlyUsedEmojis]); const headerEmojis = useMemo(() => EmojiUtils.getHeaderEmojis(allEmojis), [allEmojis]); const headerRowIndices = useMemo(() => _.map(headerEmojis, (headerEmoji) => headerEmoji.index), [headerEmojis]); + const spacersIndexes = useMemo(() => EmojiUtils.getSpacersIndexes(allEmojis), [allEmojis]); const [filteredEmojis, setFilteredEmojis] = useState(allEmojis); const [headerIndices, setHeaderIndices] = useState(headerRowIndices); const isListFiltered = allEmojis.length !== filteredEmojis.length; @@ -61,6 +62,7 @@ const useEmojiPickerMenu = () => { preferredSkinTone, listStyle, emojiListRef, + spacersIndexes, }; }; diff --git a/src/components/EnvironmentBadge.tsx b/src/components/EnvironmentBadge.tsx index ceb4acf1b9ee..9a0854b815ef 100644 --- a/src/components/EnvironmentBadge.tsx +++ b/src/components/EnvironmentBadge.tsx @@ -32,6 +32,7 @@ function EnvironmentBadge() { badgeStyles={[styles.alignSelfStart, styles.headerEnvBadge]} textStyles={[styles.headerEnvBadgeText, {fontWeight: '700'}]} environment={environment} + pressable /> ); } diff --git a/src/components/FeatureList.js b/src/components/FeatureList.js index 8e6a0d1f74d6..53d0d8f7c381 100644 --- a/src/components/FeatureList.js +++ b/src/components/FeatureList.js @@ -4,53 +4,105 @@ import {View} from 'react-native'; import _ from 'underscore'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; +import stylePropTypes from '@styles/stylePropTypes'; +import variables from '@styles/variables'; +import Button from './Button'; import MenuItem from './MenuItem'; import menuItemPropTypes from './menuItemPropTypes'; -import Text from './Text'; +import Section from './Section'; const propTypes = { + /** The text to display in the title of the section */ + title: PropTypes.string.isRequired, + + /** The text to display in the subtitle of the section */ + subtitle: PropTypes.string, + + /** Text of the call to action button */ + ctaText: PropTypes.string, + + /** Accessibility label for the call to action button */ + ctaAccessibilityLabel: PropTypes.string, + + /** Action to call on cta button press */ + onCTAPress: PropTypes.func, + /** A list of menuItems representing the feature list. */ menuItems: PropTypes.arrayOf(PropTypes.shape({...menuItemPropTypes, translationKey: PropTypes.string})).isRequired, - /** A headline translation key to show above the feature list. */ - headline: PropTypes.string.isRequired, + /** The illustration to display in the header. Can be a JSON object representing a Lottie animation. */ + illustration: PropTypes.shape({ + file: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + w: PropTypes.number.isRequired, + h: PropTypes.number.isRequired, + }), + + /** The style passed to the illustration */ + illustrationStyle: stylePropTypes, - /** A description translation key to show below the headline and above the feature list. */ - description: PropTypes.string.isRequired, + /** The background color to apply in the upper half of the screen. */ + illustrationBackgroundColor: PropTypes.string, }; -function FeatureList({menuItems, headline, description}) { +const defaultProps = { + ctaText: '', + ctaAccessibilityLabel: '', + subtitle: '', + onCTAPress: () => {}, + illustration: null, + illustrationBackgroundColor: '', + illustrationStyle: [], +}; + +function FeatureList({title, subtitle, ctaText, ctaAccessibilityLabel, onCTAPress, menuItems, illustration, illustrationStyle, illustrationBackgroundColor}) { const styles = useThemeStyles(); const {translate} = useLocalize(); + return ( - <> - - - {translate(headline)} - - {translate(description)} - - {_.map(menuItems, ({translationKey, icon}) => ( - + + + {_.map(menuItems, ({translationKey, icon}) => ( + + + + ))} + +