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 b67cb294e29b..42b24f082784 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 1001043400 - versionName "1.4.34-0" + versionCode 1001043507 + versionName "1.4.35-7" } 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 264821d4f86e..89102ecbc5e4 100644 --- a/assets/images/new-expensify.svg +++ b/assets/images/new-expensify.svg @@ -1 +1 @@ - + 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/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/_sass/_main.scss b/docs/_sass/_main.scss index cfdf4ff3a2bc..e05f7d4c08ea 100644 --- a/docs/_sass/_main.scss +++ b/docs/_sass/_main.scss @@ -458,6 +458,26 @@ button { opacity: 0.8; } } + + .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; + } + } } } diff --git a/docs/articles/new-expensify/account-settings/Preferences.md b/docs/articles/new-expensify/account-settings/Preferences.md new file mode 100644 index 000000000000..b94c9d35c1a1 --- /dev/null +++ b/docs/articles/new-expensify/account-settings/Preferences.md @@ -0,0 +1,21 @@ +--- +title: Preferences +description: How to manage your Expensify Preferences +--- +# Overview +Your Preferences in Expensify allow you to customize how you use New Expensify. + +- Set your theme preference + +# How to set your theme preference in New Expensify + +To set or update your theme preference in New Expensify: +1. Go to **Settings > Preferences** +2. Tap on **Theme** +3. You can choose between the _Dark_ theme, the _Light_ theme, or _Use Device Settings_ + +_Use Device Settings_ is the default setting. + +Selecting _Use Device Settings_ will use your device's theme settings. For example, if your device is set to adjust the appearance from light to dark during the day, we'll match that. + +Your theme preference will sync across all your New Expensify apps (mobile, web, or OSX desktop apps). 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/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 364f33a02c30..cb860f7944d0 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.4.34 + 1.4.35 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 1.4.34.0 + 1.4.35.7 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 6a90575d81fd..670458770c4e 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.4.34 + 1.4.35 CFBundleSignature ???? CFBundleVersion - 1.4.34.0 + 1.4.35.7 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index e90a61461c2b..7fb3a7cd6c50 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 1.4.34 + 1.4.35 CFBundleVersion - 1.4.34.0 + 1.4.35.7 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 e31aa2e80538..2ce5f59e1343 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,17 @@ { "name": "new.expensify", - "version": "1.4.34-0", + "version": "1.4.35-7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.4.34-0", + "version": "1.4.35-7", "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 92c906251fdb..3721ecea92f7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.4.34-0", + "version": "1.4.35-7", "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..69933a623bed 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -490,6 +490,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 +1015,7 @@ const CONST = { 3: 100, }, }, + CENTRAL_PANE_ANIMATION_HEIGHT: 200, LHN_SKELETON_VIEW_ITEM_HEIGHT: 64, EXPENSIFY_PARTNER_NAME: 'expensify.com', EMAIL: { @@ -1331,6 +1334,7 @@ const CONST = { REIMBURSEMENT_MANUAL: 'reimburseManual', }, ID_FAKE: '_FAKE_', + EMPTY: 'EMPTY', }, CUSTOM_UNITS: { @@ -1481,6 +1485,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 +1498,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 +3110,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 +3168,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 c90a7a6a7c74..88b740e0e6c8 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -243,6 +243,12 @@ 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', + /** Indicates whether an forced upgrade is required */ UPDATE_REQUIRED: 'updateRequired', @@ -259,7 +265,6 @@ const ONYXKEYS = { POLICY_TAX_RATE: 'policyTaxRates_', POLICY_RECENTLY_USED_TAGS: 'policyRecentlyUsedTags_', POLICY_REPORT_FIELDS: 'policyReportFields_', - POLICY_RECENTLY_USED_REPORT_FIELDS: 'policyRecentlyUsedReportFields_', WORKSPACE_INVITE_MEMBERS_DRAFT: 'workspaceInviteMembersDraft_', WORKSPACE_INVITE_MESSAGE_DRAFT: 'workspaceInviteMessageDraft_', REPORT: 'report_', @@ -356,8 +361,8 @@ const ONYXKEYS = { REPORT_VIRTUAL_CARD_FRAUD_DRAFT: 'reportVirtualCardFraudFormDraft', GET_PHYSICAL_CARD_FORM: 'getPhysicalCardForm', GET_PHYSICAL_CARD_FORM_DRAFT: 'getPhysicalCardFormDraft', - POLICY_REPORT_FIELD_EDIT_FORM: 'policyReportFieldEditForm', - POLICY_REPORT_FIELD_EDIT_FORM_DRAFT: 'policyReportFieldEditFormDraft', + REPORT_FIELD_EDIT_FORM: 'reportFieldEditForm', + REPORT_FIELD_EDIT_FORM_DRAFT: 'reportFieldEditFormDraft', REIMBURSEMENT_ACCOUNT_FORM: 'reimbursementAccount', REIMBURSEMENT_ACCOUNT_FORM_DRAFT: 'reimbursementAccountDraft', PERSONAL_BANK_ACCOUNT: 'personalBankAccountForm', @@ -445,6 +450,8 @@ 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; // Collections @@ -457,7 +464,6 @@ type OnyxValues = { [ONYXKEYS.COLLECTION.POLICY_MEMBERS_DRAFTS]: OnyxTypes.PolicyMember; [ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES]: OnyxTypes.RecentlyUsedCategories; [ONYXKEYS.COLLECTION.POLICY_REPORT_FIELDS]: OnyxTypes.PolicyReportFields; - [ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_REPORT_FIELDS]: OnyxTypes.RecentlyUsedReportFields; [ONYXKEYS.COLLECTION.DEPRECATED_POLICY_MEMBER_LIST]: OnyxTypes.PolicyMembers; [ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT]: Record; [ONYXKEYS.COLLECTION.REPORT]: OnyxTypes.Report; @@ -482,8 +488,8 @@ 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; @@ -542,8 +548,8 @@ type OnyxValues = { [ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM_DRAFT]: OnyxTypes.Form; [ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM]: OnyxTypes.Form; [ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM_DRAFT]: OnyxTypes.Form; - [ONYXKEYS.FORMS.POLICY_REPORT_FIELD_EDIT_FORM]: OnyxTypes.Form; - [ONYXKEYS.FORMS.POLICY_REPORT_FIELD_EDIT_FORM_DRAFT]: OnyxTypes.Form; + [ONYXKEYS.FORMS.REPORT_FIELD_EDIT_FORM]: OnyxTypes.ReportFieldEditForm; + [ONYXKEYS.FORMS.REPORT_FIELD_EDIT_FORM_DRAFT]: OnyxTypes.Form; // @ts-expect-error Different values are defined under the same key: ReimbursementAccount and ReimbursementAccountForm [ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM]: OnyxTypes.Form; [ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT]: OnyxTypes.Form; 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/AnonymousReportFooter.tsx b/src/components/AnonymousReportFooter.tsx index 7d00bac54dca..078b850de5ff 100644 --- a/src/components/AnonymousReportFooter.tsx +++ b/src/components/AnonymousReportFooter.tsx @@ -1,27 +1,31 @@ import React from 'react'; import {View} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import * as Session from '@userActions/Session'; -import type {PersonalDetailsList, Report} from '@src/types/onyx'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy, Report} from '@src/types/onyx'; import AvatarWithDisplayName from './AvatarWithDisplayName'; import Button from './Button'; import ExpensifyWordmark from './ExpensifyWordmark'; import Text from './Text'; -type AnonymousReportFooterProps = { +type AnonymousReportFooterPropsWithOnyx = { + /** The policy which the user has access to and which the report is tied to */ + policy: OnyxEntry; +}; + +type AnonymousReportFooterProps = AnonymousReportFooterPropsWithOnyx & { /** The report currently being looked at */ report: OnyxEntry; /** Whether the small screen size layout should be used */ isSmallSizeLayout?: boolean; - - /** Personal details of all the users */ - personalDetails: OnyxEntry; }; -function AnonymousReportFooter({isSmallSizeLayout = false, personalDetails, report}: AnonymousReportFooterProps) { +function AnonymousReportFooter({isSmallSizeLayout = false, report, policy}: AnonymousReportFooterProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -30,9 +34,9 @@ function AnonymousReportFooter({isSmallSizeLayout = false, personalDetails, repo @@ -57,4 +61,8 @@ function AnonymousReportFooter({isSmallSizeLayout = false, personalDetails, repo AnonymousReportFooter.displayName = 'AnonymousReportFooter'; -export default AnonymousReportFooter; +export default withOnyx({ + policy: { + key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`, + }, +})(AnonymousReportFooter); diff --git a/src/components/AttachmentModal.tsx b/src/components/AttachmentModal.tsx index f3e8ed316c52..90954c63b751 100755 --- a/src/components/AttachmentModal.tsx +++ b/src/components/AttachmentModal.tsx @@ -1,5 +1,5 @@ import Str from 'expensify-common/lib/str'; -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {memo, useCallback, useEffect, useMemo, useState} from 'react'; import {Animated, Keyboard, View} from 'react-native'; import {GestureHandlerRootView} from 'react-native-gesture-handler'; import {withOnyx} from 'react-native-onyx'; @@ -130,6 +130,9 @@ type AttachmentModalProps = AttachmentModalOnyxProps & { /** Denotes whether it is a workspace avatar or not */ isWorkspaceAvatar?: boolean; + /** Denotes whether it can be an icon (ex: SVG) */ + maybeIcon?: boolean; + /** Whether it is a receipt attachment or not */ isReceiptAttachment?: boolean; @@ -154,6 +157,7 @@ function AttachmentModal({ onCarouselAttachmentChange = () => {}, isReceiptAttachment = false, isWorkspaceAvatar = false, + maybeIcon = false, transaction, parentReport, parentReportActions, @@ -531,6 +535,7 @@ function AttachmentModal({ file={file} onToggleKeyboard={updateConfirmButtonVisibility} isWorkspaceAvatar={isWorkspaceAvatar} + maybeIcon={maybeIcon} fallbackSource={fallbackSource} isUsedInAttachmentModal transactionID={transaction?.transactionID} @@ -610,6 +615,6 @@ export default withOnyx({ key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report ? report.parentReportID : '0'}`, canEvict: false, }, -})(AttachmentModal); +})(memo(AttachmentModal)); export type {Attachment}; diff --git a/src/components/Attachments/AttachmentView/index.js b/src/components/Attachments/AttachmentView/index.js index 67f6dd95568e..33eab13f3851 100755 --- a/src/components/Attachments/AttachmentView/index.js +++ b/src/components/Attachments/AttachmentView/index.js @@ -48,6 +48,9 @@ const propTypes = { /** Denotes whether it is a workspace avatar or not */ isWorkspaceAvatar: PropTypes.bool, + /** Denotes whether it is an icon (ex: SVG) */ + maybeIcon: PropTypes.bool, + /** The id of the transaction related to the attachment */ // eslint-disable-next-line react/no-unused-prop-types transactionID: PropTypes.string, @@ -60,6 +63,7 @@ const defaultProps = { onToggleKeyboard: () => {}, containerStyles: [], isWorkspaceAvatar: false, + maybeIcon: false, transactionID: '', }; @@ -80,6 +84,7 @@ function AttachmentView({ carouselActiveItemIndex, isUsedInAttachmentModal, isWorkspaceAvatar, + maybeIcon, fallbackSource, transaction, }) { @@ -91,8 +96,9 @@ function AttachmentView({ useNetwork({onReconnect: () => setImageError(false)}); - // Handles case where source is a component (ex: SVG) - if (_.isFunction(source)) { + // Handles case where source is a component (ex: SVG) or a number + // Number may represent a SVG or an image + if ((maybeIcon && typeof source === 'number') || _.isFunction(source)) { let iconFillColor = ''; let additionalStyles = []; if (isWorkspaceAvatar) { 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 > ; + + /** Personal details of all users */ + personalDetails: OnyxCollection; }; type AvatarWithDisplayNameProps = AvatarWithDisplayNamePropsWithOnyx & { @@ -35,9 +38,6 @@ type AvatarWithDisplayNameProps = AvatarWithDisplayNamePropsWithOnyx & { /** The size of the avatar */ size?: ValueOf; - /** Personal details of all the users */ - personalDetails: OnyxEntry; - /** Whether if it's an unauthenticated user */ isAnonymous?: boolean; @@ -46,13 +46,13 @@ type AvatarWithDisplayNameProps = AvatarWithDisplayNamePropsWithOnyx & { }; function AvatarWithDisplayName({ - personalDetails, policy, report, parentReportActions, isAnonymous = false, size = CONST.AVATAR_SIZE.DEFAULT, shouldEnableDetailPageNavigation = false, + personalDetails = CONST.EMPTY_OBJECT, }: AvatarWithDisplayNameProps) { const theme = useTheme(); const styles = useThemeStyles(); @@ -181,4 +181,7 @@ export default withOnyx `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report ? report.parentReportID : '0'}`, canEvict: false, }, + personalDetails: { + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + }, })(AvatarWithDisplayName); diff --git a/src/components/AvatarWithImagePicker.js b/src/components/AvatarWithImagePicker.js index 0b1b91b28964..010d074d1da6 100644 --- a/src/components/AvatarWithImagePicker.js +++ b/src/components/AvatarWithImagePicker.js @@ -6,6 +6,7 @@ import _ from 'underscore'; import useLocalize from '@hooks/useLocalize'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; import * as Browser from '@libs/Browser'; import * as FileUtils from '@libs/fileDownload/FileUtils'; import getImageResolution from '@libs/fileDownload/getImageResolution'; @@ -33,6 +34,9 @@ const propTypes = { /** Additional style props */ style: stylePropTypes, + /** Additional style props for disabled picker */ + disabledStyle: stylePropTypes, + /** Executed once an image has been selected */ onImageSelected: PropTypes.func, @@ -45,16 +49,8 @@ const propTypes = { /** Whether we are using the default avatar */ isUsingDefaultAvatar: PropTypes.bool, - /** The anchor position of the menu */ - anchorPosition: PropTypes.shape({ - top: PropTypes.number, - right: PropTypes.number, - bottom: PropTypes.number, - left: PropTypes.number, - }).isRequired, - /** Size of Indicator */ - size: PropTypes.oneOf([CONST.AVATAR_SIZE.LARGE, CONST.AVATAR_SIZE.DEFAULT]), + size: PropTypes.oneOf([CONST.AVATAR_SIZE.XLARGE, CONST.AVATAR_SIZE.LARGE, CONST.AVATAR_SIZE.DEFAULT]), /** A fallback avatar icon to display when there is an error on loading avatar from remote URL. */ fallbackIcon: sourcePropTypes, @@ -90,6 +86,12 @@ const propTypes = { /** Whether navigation is focused */ isFocused: PropTypes.bool.isRequired, + /** Style applied to the avatar */ + avatarStyle: stylePropTypes.isRequired, + + /** Indicates if picker feature should be disabled */ + disabled: PropTypes.bool, + /** Executed once click on view photo option */ onViewPhotoPress: PropTypes.func, @@ -105,6 +107,7 @@ const defaultProps = { onImageSelected: () => {}, 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 && ( + + + + )} @@ -338,6 +369,7 @@ function AvatarWithImagePicker({ source={previewSource} originalFileName={originalFileName} fallbackSource={fallbackIcon} + maybeIcon={isUsingDefaultAvatar} > {({show}) => ( @@ -375,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/ButtonWithDropdownMenu.tsx b/src/components/ButtonWithDropdownMenu.tsx index 466c68229a32..676912de6b60 100644 --- a/src/components/ButtonWithDropdownMenu.tsx +++ b/src/components/ButtonWithDropdownMenu.tsx @@ -32,6 +32,9 @@ type ButtonWithDropdownMenuProps = { /** Callback to execute when the main button is pressed */ onPress: (event: GestureResponderEvent | KeyboardEvent | undefined, value: string) => void; + /** Callback to execute when a dropdown option is selected */ + onOptionSelected?: (option: DropdownOption) => void; + /** Call the onPress function on main button when Enter key is pressed */ pressOnEnter?: boolean; @@ -72,6 +75,7 @@ function ButtonWithDropdownMenu({ buttonRef, onPress, options, + onOptionSelected, }: ButtonWithDropdownMenuProps) { const theme = useTheme(); const styles = useThemeStyles(); @@ -174,6 +178,7 @@ function ButtonWithDropdownMenu({ menuItems={options.map((item, index) => ({ ...item, onSelected: () => { + onOptionSelected?.(item); setSelectedItemIndex(index); }, }))} 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/EmojiPicker/EmojiPickerButton.js b/src/components/EmojiPicker/EmojiPickerButton.js index 832715e3214c..438deb7e53d9 100644 --- a/src/components/EmojiPicker/EmojiPickerButton.js +++ b/src/components/EmojiPicker/EmojiPickerButton.js @@ -1,5 +1,5 @@ import PropTypes from 'prop-types'; -import React, {useEffect, useRef} from 'react'; +import React, {memo, useEffect, useRef} from 'react'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; @@ -87,4 +87,4 @@ function EmojiPickerButton(props) { EmojiPickerButton.propTypes = propTypes; EmojiPickerButton.defaultProps = defaultProps; EmojiPickerButton.displayName = 'EmojiPickerButton'; -export default compose(withLocalize, withNavigationFocus)(EmojiPickerButton); +export default compose(withLocalize, withNavigationFocus)(memo(EmojiPickerButton)); 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/ExceededCommentLength.tsx b/src/components/ExceededCommentLength.tsx index 6cd11cc44a5c..2f0887afc8f1 100644 --- a/src/components/ExceededCommentLength.tsx +++ b/src/components/ExceededCommentLength.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, {memo} from 'react'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; @@ -20,4 +20,4 @@ function ExceededCommentLength() { ExceededCommentLength.displayName = 'ExceededCommentLength'; -export default ExceededCommentLength; +export default memo(ExceededCommentLength); 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}) => ( + + + + ))} + +