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..09fdba1b69af 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 1001043504 + versionName "1.4.35-4" } 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/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/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/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..e6affbb6db34 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -40,7 +40,7 @@ CFBundleVersion - 1.4.35.0 + 1.4.35.4 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 6b39db8b2f27..4f46e8c52a85 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -19,6 +19,6 @@ CFBundleSignature ???? CFBundleVersion - 1.4.35.0 + 1.4.35.4 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index ff9b56d72408..228eede8c92f 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -13,7 +13,7 @@ CFBundleShortVersionString 1.4.35 CFBundleVersion - 1.4.35.0 + 1.4.35.4 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..358cc7f79c62 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,17 @@ { "name": "new.expensify", - "version": "1.4.35-0", + "version": "1.4.35-4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.4.35-0", + "version": "1.4.35-4", "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..e6e81e12c943 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.4.35-0", + "version": "1.4.35-4", "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/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 2867cb3905a2..88b740e0e6c8 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; @@ -484,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; 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/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}) => ( + + + + ))} + +