diff --git a/.eslintrc.js b/.eslintrc.js index ac352db40431..2f9a4167ae51 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -31,6 +31,11 @@ module.exports = { importNames: ['TouchableOpacity', 'TouchableWithoutFeedback', 'TouchableNativeFeedback', 'TouchableHighlight'], message: 'Please use PressableWithFeedback and/or PressableWithoutFeedback from src/components/Pressable instead', }, + { + name: 'react-native', + importNames: ['StatusBar'], + message: 'Please use StatusBar from src/libs/StatusBar instead', + }, ], }, ], diff --git a/.github/actions/composite/setupNode/action.yml b/.github/actions/composite/setupNode/action.yml index 643c707da230..6bdf500912c0 100644 --- a/.github/actions/composite/setupNode/action.yml +++ b/.github/actions/composite/setupNode/action.yml @@ -8,10 +8,34 @@ runs: with: node-version-file: '.nvmrc' cache: npm + cache-dependency-path: | + package-lock.json + desktop/package-lock.json - - name: Install node packages - uses: nick-invision/retry@0711ba3d7808574133d713a0d92d2941be03a350 + - id: cache-node-modules + uses: actions/cache@v3 + with: + path: node_modules + key: ${{ runner.os }}-node-modules-${{ hashFiles('package-lock.json') }} + + - id: cache-desktop-node-modules + uses: actions/cache@v3 + with: + path: desktop/node_modules + key: ${{ runner.os }}-desktop-node-modules-${{ hashFiles('desktop/package-lock.json') }} + + - name: Install root project node packages + if: steps.cache-node-modules.outputs.cache-hit != 'true' + uses: nick-fields/retry@v2 with: timeout_minutes: 30 max_attempts: 3 command: npm ci + + - name: Install node packages for desktop submodule + if: steps.cache-desktop-node-modules.outputs.cache-hit != 'true' + uses: nick-fields/retry@v2 + with: + timeout_minutes: 30 + max_attempts: 3 + command: cd desktop && npm ci diff --git a/.github/workflows/platformDeploy.yml b/.github/workflows/platformDeploy.yml index 48c8e38fe3e1..bac7ab34920d 100644 --- a/.github/workflows/platformDeploy.yml +++ b/.github/workflows/platformDeploy.yml @@ -158,13 +158,13 @@ jobs: bundler-cache: true - uses: actions/cache@v3 + id: cache-pods with: path: ios/Pods key: ${{ runner.os }}-pods-${{ hashFiles('**/Podfile.lock') }} - restore-keys: | - ${{ runner.os }}-pods- - name: Install cocoapods + if: steps.cache-pods.outputs.cache-hit != 'true' uses: nick-invision/retry@0711ba3d7808574133d713a0d92d2941be03a350 with: timeout_minutes: 10 diff --git a/.github/workflows/testBuild.yml b/.github/workflows/testBuild.yml index 9b4bf6d020d6..b75ee2a402e4 100644 --- a/.github/workflows/testBuild.yml +++ b/.github/workflows/testBuild.yml @@ -141,13 +141,13 @@ jobs: bundler-cache: true - uses: actions/cache@v3 + id: cache-pods with: path: ios/Pods key: ${{ runner.os }}-pods-${{ hashFiles('**/Podfile.lock') }} - restore-keys: | - ${{ runner.os }}-pods- - name: Install cocoapods + if: steps.cache-pods.outputs.cache-hit != 'true' uses: nick-invision/retry@0711ba3d7808574133d713a0d92d2941be03a350 with: timeout_minutes: 10 diff --git a/.well-known/apple-app-site-association b/.well-known/apple-app-site-association index fcd7138da7e6..7568dbb55eb0 100644 --- a/.well-known/apple-app-site-association +++ b/.well-known/apple-app-site-association @@ -12,10 +12,6 @@ "/": "/settings/*", "comment": "Profile and app settings" }, - { - "/": "/setpassword/*", - "comment": "Passoword setup" - }, { "/": "/details/*", "comment": "Details of another users" diff --git a/android/app/build.gradle b/android/app/build.gradle index 0976b66e69bd..c29f64297d23 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -106,8 +106,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001032507 - versionName "1.3.25-7" + versionCode 1001032601 + versionName "1.3.26-1" } splits { diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 05302f43ef8d..e79b72f0e904 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -52,7 +52,6 @@ - @@ -64,7 +63,6 @@ - diff --git a/docs/articles/other/Everything-About-Chat.md b/docs/articles/other/Everything-About-Chat.md index 8d4ad5f8740c..521bcd6eca25 100644 --- a/docs/articles/other/Everything-About-Chat.md +++ b/docs/articles/other/Everything-About-Chat.md @@ -23,6 +23,37 @@ In addition to 1:1 and group chat, members of a Workspace or Policy will have ac All workspace members are added to the #announce room by default. The #announce room lets you share important company announcements and have conversations between workspace members. All workspace admins can access the #admins room. Use the #admins room to collaborate with the other admins on your policy, and chat with your dedicated Expensify Onboarding Guide. If you have a subscription of 10 or more users, you're automatically assigned an Account Manager. You can ask for help and collaborate with your Account Manager in this same #admins room. Anytime someone on your team, your dedicated setup specialist, or your dedicated account manager makes any changes to your Workspace settings, that update is logged in the #admins room. +## How to format text + +#### Italic +###### To italicize your message, place an underscore on both sides of the text: +*text* + +#### Bold +###### To bold your message, place an asterisk on both sides of the text: +**text** + +#### Strikethrough +###### To strikethrough your message, place a tilde on both sides of the text: +~text~ + +#### Quote +###### To turn your text into a blockquote, add a `>` symbol in front of the text: +> your text + +#### Code +###### To turn your message into code, place a backtick on both sides of the text: +`text` + +#### Codeblock +###### To turn your entire message into code block, place three backticks on both sides of the text: +``` +text +and even more text +``` +#### Heading +###### To turn your message into a heading, place the `#` symbol in front of the text: +## Heading # FAQs ## How do I add more than one person to a chat? diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 38c7628b753b..4a2406aaa1b8 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.3.25 + 1.3.26 CFBundleSignature ???? CFBundleURLTypes @@ -32,7 +32,7 @@ CFBundleVersion - 1.3.25.7 + 1.3.26.1 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index bf23732dceb4..591c2f2b999a 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.3.25 + 1.3.26 CFBundleSignature ???? CFBundleVersion - 1.3.25.7 + 1.3.26.1 diff --git a/package-lock.json b/package-lock.json index d0405cf47463..82d99eaa3ab6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.3.25-7", + "version": "1.3.26-1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.3.25-7", + "version": "1.3.26-1", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -40,7 +40,7 @@ "babel-polyfill": "^6.26.0", "dom-serializer": "^0.2.2", "domhandler": "^4.3.0", - "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#925e62744f784a92c3ad8947aabfc21c93bec221", + "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#50aacd53fe54ef7131e5cb9c74ee4526b3bcfe16", "fbjs": "^3.0.2", "html-entities": "^1.3.1", "htmlparser2": "^7.2.0", @@ -24054,8 +24054,8 @@ }, "node_modules/expensify-common": { "version": "1.0.0", - "resolved": "git+ssh://git@github.com/Expensify/expensify-common.git#925e62744f784a92c3ad8947aabfc21c93bec221", - "integrity": "sha512-c6Q2xPI5pzU1mlrW65HKZ6mn5BJvoCgC1wO/sf4Yw+p5XYNQbolj0WYvGLtSxIw/x9G4M+H8bQo7qRd1ckt+5w==", + "resolved": "git+ssh://git@github.com/Expensify/expensify-common.git#50aacd53fe54ef7131e5cb9c74ee4526b3bcfe16", + "integrity": "sha512-wnx6WiG2NVqIs2m3M0VjL/kw2I1rgWOD06mNp6FwRGeOVC+QIL6hsdJgqf9z9NjBSuhc6THfzbcrAOSGs3kTkw==", "license": "MIT", "dependencies": { "classnames": "2.3.1", @@ -59286,9 +59286,9 @@ } }, "expensify-common": { - "version": "git+ssh://git@github.com/Expensify/expensify-common.git#925e62744f784a92c3ad8947aabfc21c93bec221", - "integrity": "sha512-c6Q2xPI5pzU1mlrW65HKZ6mn5BJvoCgC1wO/sf4Yw+p5XYNQbolj0WYvGLtSxIw/x9G4M+H8bQo7qRd1ckt+5w==", - "from": "expensify-common@git+ssh://git@github.com/Expensify/expensify-common.git#925e62744f784a92c3ad8947aabfc21c93bec221", + "version": "git+ssh://git@github.com/Expensify/expensify-common.git#50aacd53fe54ef7131e5cb9c74ee4526b3bcfe16", + "integrity": "sha512-wnx6WiG2NVqIs2m3M0VjL/kw2I1rgWOD06mNp6FwRGeOVC+QIL6hsdJgqf9z9NjBSuhc6THfzbcrAOSGs3kTkw==", + "from": "expensify-common@git+ssh://git@github.com/Expensify/expensify-common.git#50aacd53fe54ef7131e5cb9c74ee4526b3bcfe16", "requires": { "classnames": "2.3.1", "clipboard": "2.0.4", diff --git a/package.json b/package.json index c58561daea9e..14f859fe945a 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,13 @@ { "name": "new.expensify", - "version": "1.3.25-7", + "version": "1.3.26-1", "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.", "license": "MIT", "private": true, "scripts": { - "postinstall": "npx patch-package && cd desktop && npm install", + "postinstall": "scripts/postInstall.sh", "clean": "npx react-native clean-project-auto", "android": "scripts/set-pusher-suffix.sh && npx react-native run-android --port=8083", "ios": "scripts/set-pusher-suffix.sh && npx react-native run-ios --port=8082", @@ -76,7 +76,7 @@ "babel-polyfill": "^6.26.0", "dom-serializer": "^0.2.2", "domhandler": "^4.3.0", - "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#925e62744f784a92c3ad8947aabfc21c93bec221", + "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#50aacd53fe54ef7131e5cb9c74ee4526b3bcfe16", "fbjs": "^3.0.2", "html-entities": "^1.3.1", "htmlparser2": "^7.2.0", diff --git a/scripts/postInstall.sh b/scripts/postInstall.sh new file mode 100755 index 000000000000..02ec3caa5175 --- /dev/null +++ b/scripts/postInstall.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +# Go to project root +ROOT_DIR=$(dirname "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &> /dev/null && pwd)") +cd "$ROOT_DIR" || exit 1 + +# Run patch-package +npx patch-package + +# Install node_modules in subpackages, unless we're in a CI/CD environment, +# where the node_modules for subpackages are cached separately. +# See `.github/actions/composite/setupNode/action.yml` for more context. +if [[ -n ${CI+x} ]]; then + cd desktop || exit 1 + npm install +fi diff --git a/src/CONST.js b/src/CONST.js index 156df68f4836..bcc1941fcb39 100755 --- a/src/CONST.js +++ b/src/CONST.js @@ -829,6 +829,7 @@ const CONST = { WIDTH: 320, HEIGHT: 416, }, + DESKTOP_HEADER_PADDING: 12, CATEGORY_SHORTCUT_BAR_HEIGHT: 32, SMALL_EMOJI_PICKER_SIZE: { WIDTH: '100%', @@ -1107,7 +1108,7 @@ const CONST = { CARD_NUMBER: /^[0-9]{15,16}$/, CARD_SECURITY_CODE: /^[0-9]{3,4}$/, CARD_EXPIRATION_DATE: /^(0[1-9]|1[0-2])([^0-9])?([0-9]{4}|([0-9]{2}))$/, - PAYPAL_ME_USERNAME: /^[a-zA-Z0-9]+$/, + PAYPAL_ME_USERNAME: /^[a-zA-Z0-9]{1,20}$/, ROOM_NAME: /^#[a-z0-9-]{1,80}$/, // eslint-disable-next-line max-len, no-misleading-character-class @@ -2436,9 +2437,9 @@ const CONST = { }, SPACE_CHARACTER_WIDTH: 4, - // This ID is used in SelectionScraper.js to query the DOM for UnreadActionIndicator's - // div and then remove it from copied contents in the getHTMLOfSelection() method. - UNREAD_ACTION_INDICATOR_ID: 'no-copy-area-unread-action-indicator', + // The attribute used in the SelectionScraper.js helper to query all the DOM elements + // that should be removed from the copied contents in the getHTMLOfSelection() method + SELECTION_SCRAPER_HIDDEN_ELEMENT: 'selection-scrapper-hidden-element', MODERATION: { MODERATOR_DECISION_PENDING: 'pending', MODERATOR_DECISION_PENDING_HIDE: 'pendingHide', diff --git a/src/ONYXKEYS.js b/src/ONYXKEYS.js index 2b4e81205590..082a1c7efbd5 100755 --- a/src/ONYXKEYS.js +++ b/src/ONYXKEYS.js @@ -45,6 +45,9 @@ export default { // Contains all the personalDetails the user has access to PERSONAL_DETAILS: 'personalDetails', + // Contains all the personalDetails the user has access to, keyed by accountID + PERSONAL_DETAILS_LIST: 'personalDetailsList', + // Contains all the private personal details of the user PRIVATE_PERSONAL_DETAILS: 'private_personalDetails', diff --git a/src/ROUTES.js b/src/ROUTES.js index bd0ad304c2f5..966c3d0c5a1a 100644 --- a/src/ROUTES.js +++ b/src/ROUTES.js @@ -111,13 +111,14 @@ export default { FLAG_COMMENT: `flag/:reportID/:reportActionID`, getFlagCommentRoute: (reportID, reportActionID) => `flag/${reportID}/${reportActionID}`, SEARCH: 'search', - SET_PASSWORD_WITH_VALIDATE_CODE: 'setpassword/:accountID/:validateCode', DETAILS: 'details', getDetailsRoute: (login) => `details?login=${encodeURIComponent(login)}`, + PROFILE: 'a/:accountID', + getProfileRoute: (accountID) => `a/${accountID}`, REPORT_PARTICIPANTS: 'r/:reportID/participants', getReportParticipantsRoute: (reportID) => `r/${reportID}/participants`, - REPORT_PARTICIPANT: 'r/:reportID/participants/details', - getReportParticipantRoute: (reportID, login) => `r/${reportID}/participants/details?login=${encodeURIComponent(login)}`, + REPORT_PARTICIPANT: 'r/:reportID/participants/a/:accountID', + getReportParticipantRoute: (reportID, accountID) => `r/${reportID}/participants/a/${accountID}`, REPORT_WITH_ID_DETAILS: 'r/:reportID/details', getReportDetailsRoute: (reportID) => `r/${reportID}/details`, REPORT_SETTINGS: 'r/:reportID/settings', diff --git a/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly.js b/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly.js index 21c1ea618841..1f95ead89a4c 100644 --- a/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly.js +++ b/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly.js @@ -1,6 +1,6 @@ import _ from 'underscore'; import React from 'react'; -import {View, StyleSheet} from 'react-native'; +import {StyleSheet} from 'react-native'; import PropTypes from 'prop-types'; import lodashGet from 'lodash/get'; import Str from 'expensify-common/lib/str'; @@ -11,6 +11,7 @@ import * as ContextMenuActions from '../../pages/home/report/ContextMenu/Context import Tooltip from '../Tooltip'; import * as DeviceCapabilities from '../../libs/DeviceCapabilities'; import styles from '../../styles/styles'; +import * as StyleUtils from '../../styles/StyleUtils'; import withWindowDimensions, {windowDimensionsPropTypes} from '../withWindowDimensions'; import {propTypes as anchorForCommentsOnlyPropTypes, defaultProps as anchorForCommentsOnlyDefaultProps} from './anchorForCommentsOnlyPropTypes'; @@ -51,41 +52,40 @@ const BaseAnchorForCommentsOnly = (props) => { const isEmail = Str.isValidEmailMarkdown(props.href.replace(/mailto:/i, '')); return ( - - { - ReportActionContextMenu.showContextMenu( - isEmail ? ContextMenuActions.CONTEXT_MENU_TYPES.EMAIL : ContextMenuActions.CONTEXT_MENU_TYPES.LINK, - event, - props.href, - lodashGet(linkRef, 'current'), - ); - }} - onPress={linkProps.onPress} - onPressIn={props.onPressIn} - onPressOut={props.onPressOut} - > - - (linkRef = el)} - style={StyleSheet.flatten([props.style, defaultTextStyle])} - accessibilityRole="link" - hrefAttrs={{ - rel: props.rel, - target: isEmail ? '_self' : props.target, - }} - href={linkProps.href} - // Add testID so it gets selected as an anchor tag by SelectionScraper - testID="a" - // eslint-disable-next-line react/jsx-props-no-spreading - {...rest} - > - {props.children} - - - - + { + ReportActionContextMenu.showContextMenu( + isEmail ? ContextMenuActions.CONTEXT_MENU_TYPES.EMAIL : ContextMenuActions.CONTEXT_MENU_TYPES.LINK, + event, + props.href, + lodashGet(linkRef, 'current'), + ); + }} + onPress={linkProps.onPress} + onPressIn={props.onPressIn} + onPressOut={props.onPressOut} + > + + (linkRef = el)} + style={StyleSheet.flatten([props.style, defaultTextStyle])} + accessibilityRole="link" + hrefAttrs={{ + rel: props.rel, + target: isEmail ? '_self' : props.target, + }} + href={linkProps.href} + // Add testID so it gets selected as an anchor tag by SelectionScraper + testID="a" + // eslint-disable-next-line react/jsx-props-no-spreading + {...rest} + > + {props.children} + + + ); }; diff --git a/src/components/AnchorForCommentsOnly/index.js b/src/components/AnchorForCommentsOnly/index.js index fd670809e2cf..1ea73d5a648d 100644 --- a/src/components/AnchorForCommentsOnly/index.js +++ b/src/components/AnchorForCommentsOnly/index.js @@ -3,7 +3,6 @@ import * as anchorForCommentsOnlyPropTypes from './anchorForCommentsOnlyPropType import BaseAnchorForCommentsOnly from './BaseAnchorForCommentsOnly'; import * as DeviceCapabilities from '../../libs/DeviceCapabilities'; import ControlSelection from '../../libs/ControlSelection'; -import styles from '../../styles/styles'; const AnchorForCommentsOnly = (props) => ( ( {...props} onPressIn={() => DeviceCapabilities.canUseTouchScreen() && ControlSelection.block()} onPressOut={() => ControlSelection.unblock()} - // HTML renderer root view display is flex. Using flex will force all child elements - // to be block elements even when they have display inline added to them. - // This will affect elements like which are inline by default. - // Setting display block to the container view will solve that. - containerStyles={[styles.dBlock]} /> ); diff --git a/src/components/AttachmentCarousel/CarouselActions/index.native.js b/src/components/AttachmentCarousel/CarouselActions.js similarity index 69% rename from src/components/AttachmentCarousel/CarouselActions/index.native.js rename to src/components/AttachmentCarousel/CarouselActions.js index d12cd6bfbb60..3946a613ee16 100644 --- a/src/components/AttachmentCarousel/CarouselActions/index.native.js +++ b/src/components/AttachmentCarousel/CarouselActions.js @@ -1,7 +1,8 @@ import {useEffect} from 'react'; +import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; -import KeyboardShortcut from '../../../libs/KeyboardShortcut'; -import CONST from '../../../CONST'; +import KeyboardShortcut from '../../libs/KeyboardShortcut'; +import CONST from '../../CONST'; const propTypes = { /** Callback to cycle through attachments */ @@ -13,7 +14,12 @@ const Carousel = (props) => { const shortcutLeftConfig = CONST.KEYBOARD_SHORTCUTS.ARROW_LEFT; const unsubscribeLeftKey = KeyboardShortcut.subscribe( shortcutLeftConfig.shortcutKey, - () => { + (e) => { + if (lodashGet(e, 'target.blur')) { + // prevents focus from highlighting around the modal + e.target.blur(); + } + props.onCycleThroughAttachments(-1); }, shortcutLeftConfig.descriptionKey, @@ -23,7 +29,12 @@ const Carousel = (props) => { const shortcutRightConfig = CONST.KEYBOARD_SHORTCUTS.ARROW_RIGHT; const unsubscribeRightKey = KeyboardShortcut.subscribe( shortcutRightConfig.shortcutKey, - () => { + (e) => { + if (lodashGet(e, 'target.blur')) { + // prevents focus from highlighting around the modal + e.target.blur(); + } + props.onCycleThroughAttachments(1); }, shortcutRightConfig.descriptionKey, diff --git a/src/components/AttachmentCarousel/CarouselActions/index.js b/src/components/AttachmentCarousel/CarouselActions/index.js deleted file mode 100644 index 4ec551daa252..000000000000 --- a/src/components/AttachmentCarousel/CarouselActions/index.js +++ /dev/null @@ -1,42 +0,0 @@ -import {useCallback, useEffect} from 'react'; -import PropTypes from 'prop-types'; - -const propTypes = { - /** Callback to cycle through attachments */ - onCycleThroughAttachments: PropTypes.func.isRequired, -}; - -const Carousel = (props) => { - /** - * Listens for keyboard shortcuts and applies the action - * - * @param {Object} e - */ - const handleKeyPress = useCallback((e) => { - // prevents focus from highlighting around the modal - e.target.blur(); - - if (e.key === 'ArrowLeft') { - props.onCycleThroughAttachments(-1); - } - if (e.key === 'ArrowRight') { - props.onCycleThroughAttachments(1); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - useEffect(() => { - document.addEventListener('keydown', handleKeyPress); - - return () => { - document.removeEventListener('keydown', handleKeyPress); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - return null; -}; - -Carousel.propTypes = propTypes; - -export default Carousel; diff --git a/src/components/AttachmentCarousel/index.js b/src/components/AttachmentCarousel/index.js index 778044586172..77ab66400ae2 100644 --- a/src/components/AttachmentCarousel/index.js +++ b/src/components/AttachmentCarousel/index.js @@ -20,6 +20,7 @@ import Tooltip from '../Tooltip'; import withLocalize, {withLocalizePropTypes} from '../withLocalize'; import compose from '../../libs/compose'; import withWindowDimensions from '../withWindowDimensions'; +import reportPropTypes from '../../pages/reportPropTypes'; const propTypes = { /** source is used to determine the starting index in the array of attachments */ @@ -31,6 +32,9 @@ const propTypes = { /** Object of report actions for this report */ reportActions: PropTypes.objectOf(PropTypes.shape(reportActionPropTypes)), + /** The report currently being looked at */ + report: reportPropTypes.isRequired, + ...withLocalizePropTypes, }; @@ -148,7 +152,7 @@ class AttachmentCarousel extends React.Component { * @returns {{page: Number, attachments: Array, shouldShowArrow: Boolean, containerWidth: Number, isZoomed: Boolean}} */ createInitialState() { - const actions = ReportActionsUtils.getSortedReportActions(_.values(this.props.reportActions)); + const actions = [ReportActionsUtils.getParentReportAction(this.props.report), ...ReportActionsUtils.getSortedReportActions(_.values(this.props.reportActions))]; const attachments = []; const htmlParser = new HtmlParser({ @@ -359,7 +363,7 @@ AttachmentCarousel.defaultProps = defaultProps; export default compose( withOnyx({ reportActions: { - key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, + key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, canEvict: false, }, }), diff --git a/src/components/AttachmentModal.js b/src/components/AttachmentModal.js index b29c910e83e4..393a0085176c 100755 --- a/src/components/AttachmentModal.js +++ b/src/components/AttachmentModal.js @@ -23,6 +23,7 @@ import ConfirmModal from './ConfirmModal'; import HeaderGap from './HeaderGap'; import SafeAreaConsumer from './SafeAreaConsumer'; import addEncryptedAuthTokenToURL from '../libs/addEncryptedAuthTokenToURL'; +import reportPropTypes from '../pages/reportPropTypes'; /** * Modal render prop component that exposes modal launching triggers that can be used @@ -57,8 +58,8 @@ const propTypes = { /** Title shown in the header of the modal */ headerTitle: PropTypes.string, - /** The ID of the report that has this attachment */ - reportID: PropTypes.string, + /** The report that has this attachment */ + report: reportPropTypes, ...withLocalizePropTypes, @@ -72,7 +73,7 @@ const defaultProps = { isAuthTokenRequired: false, allowDownload: false, headerTitle: null, - reportID: '', + report: {}, onModalShow: () => {}, onModalHide: () => {}, }; @@ -287,9 +288,9 @@ class AttachmentModal extends PureComponent { onCloseButtonPress={() => this.setState({isModalOpen: false})} /> - {this.props.reportID ? ( + {!_.isEmpty(this.props.report) ? ( { * @returns {JSX.Element} */ const renderSuggestionMenuItem = ({item, index}) => ( - StyleUtils.getAutoCompleteSuggestionItemStyle(props.highlightedSuggestionIndex, CONST.AUTO_COMPLETE_SUGGESTER.ITEM_HEIGHT, hovered, index)} + hoverDimmingValue={1} onMouseDown={(e) => e.preventDefault()} onPress={() => props.onSelect(index)} onLongPress={() => {}} + accessibilityLabel={props.accessibilityLabelExtractor(item, index)} > {props.renderSuggestionMenuItem(item, index)} - + ); const innerHeight = CONST.AUTO_COMPLETE_SUGGESTER.ITEM_HEIGHT * props.suggestions.length; diff --git a/src/components/AutoCompleteSuggestions/autoCompleteSuggestionsPropTypes.js b/src/components/AutoCompleteSuggestions/autoCompleteSuggestionsPropTypes.js index eed9368e534d..6ff330d839c6 100644 --- a/src/components/AutoCompleteSuggestions/autoCompleteSuggestionsPropTypes.js +++ b/src/components/AutoCompleteSuggestions/autoCompleteSuggestionsPropTypes.js @@ -24,6 +24,9 @@ const propTypes = { /** Show that we should include ReportRecipientLocalTime view height */ shouldIncludeReportRecipientLocalTimeHeight: PropTypes.bool.isRequired, + + /** create accessibility label for each item */ + accessibilityLabelExtractor: PropTypes.func.isRequired, }; const defaultProps = {}; diff --git a/src/components/AutoEmailLink.js b/src/components/AutoEmailLink.js new file mode 100644 index 000000000000..f99c8ea76f3c --- /dev/null +++ b/src/components/AutoEmailLink.js @@ -0,0 +1,54 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import _ from 'underscore'; +import {CONST} from 'expensify-common/lib/CONST'; +import Text from './Text'; +import TextLink from './TextLink'; +import styles from '../styles/styles'; + +const propTypes = { + text: PropTypes.string.isRequired, + style: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]), +}; + +const defaultProps = { + style: [], +}; + +/* + * This is a "utility component", that does this: + * - Checks if a text contains any email. If it does, render it as a mailto: link + * - Else just render it inside `Text` component + */ + +const AutoEmailLink = (props) => ( + + {_.map(props.text.split(CONST.REG_EXP.EXTRACT_EMAIL), (str, index) => { + if (CONST.REG_EXP.EMAIL.test(str)) { + return ( + + {str} + + ); + } + + return ( + + {str} + + ); + })} + +); + +AutoEmailLink.displayName = 'AutoEmailLink'; +AutoEmailLink.propTypes = propTypes; +AutoEmailLink.defaultProps = defaultProps; +export default AutoEmailLink; diff --git a/src/components/AvatarWithImagePicker.js b/src/components/AvatarWithImagePicker.js index 843b131571ac..45e75aaeec57 100644 --- a/src/components/AvatarWithImagePicker.js +++ b/src/components/AvatarWithImagePicker.js @@ -12,6 +12,7 @@ import themeColors from '../styles/themes/default'; import AttachmentPicker from './AttachmentPicker'; import ConfirmModal from './ConfirmModal'; import AvatarCropModal from './AvatarCropModal/AvatarCropModal'; +import OfflineWithFeedback from './OfflineWithFeedback'; import withLocalize, {withLocalizePropTypes} from './withLocalize'; import variables from '../styles/variables'; import CONST from '../CONST'; @@ -64,6 +65,19 @@ const propTypes = { /** Image crop vector mask */ editorMaskImage: PropTypes.func, + /** Additional style object for the error row */ + errorRowStyles: stylePropTypes, + + /** A function to run when the X button next to the error is clicked */ + onErrorClose: PropTypes.func, + + /** The type of action that's pending */ + pendingAction: PropTypes.oneOf(['add', 'update', 'delete']), + + /** The errors to display */ + // eslint-disable-next-line react/forbid-prop-types + errors: PropTypes.object, + ...withLocalizePropTypes, }; @@ -79,6 +93,10 @@ const defaultProps = { fallbackIcon: Expensicons.FallbackAvatar, type: CONST.ICON_TYPE_AVATAR, editorMaskImage: undefined, + errorRowStyles: [], + onErrorClose: () => {}, + pendingAction: null, + errors: null, }; class AvatarWithImagePicker extends React.Component { @@ -263,22 +281,30 @@ class AvatarWithImagePicker extends React.Component { accessibilityLabel={this.props.translate('avatarWithImagePicker.editImage')} > - - - {this.props.source ? ( - - ) : ( - - )} - - + + + + {this.props.source ? ( + + ) : ( + + )} + + + + {({openPicker}) => ( <> diff --git a/src/components/BlockingViews/BlockingView.js b/src/components/BlockingViews/BlockingView.js index d3a552d83824..0ac4ec4a9e13 100644 --- a/src/components/BlockingViews/BlockingView.js +++ b/src/components/BlockingViews/BlockingView.js @@ -8,6 +8,7 @@ import Text from '../Text'; import themeColors from '../../styles/themes/default'; import TextLink from '../TextLink'; import Navigation from '../../libs/Navigation/Navigation'; +import AutoEmailLink from '../AutoEmailLink'; const propTypes = { /** Expensicon for the page */ @@ -20,7 +21,7 @@ const propTypes = { title: PropTypes.string.isRequired, /** Subtitle message below the title */ - subtitle: PropTypes.string.isRequired, + subtitle: PropTypes.string, /** Link message below the subtitle */ link: PropTypes.string, @@ -40,6 +41,7 @@ const propTypes = { const defaultProps = { iconColor: themeColors.offline, + subtitle: '', shouldShowLink: false, link: 'notFound.goBackHome', iconWidth: variables.iconSizeSuperLarge, @@ -56,7 +58,10 @@ const BlockingView = (props) => ( height={props.iconHeight} /> {props.title} - {props.subtitle} + {props.shouldShowLink ? ( { StatusBar.setBarStyle('light-content', true); - } - - render() { - return ; - } + StatusBar.setBackgroundColor(themeColors.appBG); + }, []); + return ; } + +CustomStatusBar.displayName = 'CustomStatusBar'; + +export default CustomStatusBar; diff --git a/src/components/CustomStatusBar/index.web.js b/src/components/CustomStatusBar/index.web.js deleted file mode 100644 index 4f9b8d2c044d..000000000000 --- a/src/components/CustomStatusBar/index.web.js +++ /dev/null @@ -1,19 +0,0 @@ -import React from 'react'; -import {StatusBar} from 'react-native'; -import themeColors from '../../styles/themes/default'; - -export default class CustomStatusBar extends React.Component { - componentDidMount() { - StatusBar.setBarStyle('light-content', true); - - // For mobile web browsers, match the default status bar color to the app's background color - const element = document.querySelector('meta[name=theme-color]'); - if (element) { - element.content = themeColors.appBG; - } - } - - render() { - return ; - } -} diff --git a/src/components/EmojiSuggestions.js b/src/components/EmojiSuggestions.js index c098cc988b81..2af9dfc99368 100644 --- a/src/components/EmojiSuggestions.js +++ b/src/components/EmojiSuggestions.js @@ -94,6 +94,7 @@ const EmojiSuggestions = (props) => { onSelect={props.onSelect} isSuggestionPickerLarge={props.isEmojiPickerLarge} shouldIncludeReportRecipientLocalTimeHeight={props.shouldIncludeReportRecipientLocalTimeHeight} + accessibilityLabelExtractor={keyExtractor} /> ); }; diff --git a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js index 3cc30322b5d4..dd8a06927a07 100755 --- a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js +++ b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js @@ -61,7 +61,7 @@ const BaseHTMLEngineProvider = (props) => { // We need to pass multiple system-specific fonts for emojis but // we can't apply multiple fonts at once so we need to pass fallback fonts. - const fallbackFonts = {'ExpensifyNeue-Regular': fontFamily.EMOJI_TEXT_FONT}; + const fallbackFonts = {'ExpensifyNeue-Regular': fontFamily.EXP_NEUE}; return ( { {({anchor, report, action, checkIfContextMenuActive}) => ( {({show}) => ( showContextMenuForReport(event, anchor, report.reportID, action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report))} > diff --git a/src/components/HeaderWithBackButton.js b/src/components/HeaderWithBackButton.js index dab5f024a9e6..e82251542987 100755 --- a/src/components/HeaderWithBackButton.js +++ b/src/components/HeaderWithBackButton.js @@ -1,6 +1,7 @@ import React, {Component} from 'react'; import PropTypes from 'prop-types'; import {View, Keyboard} from 'react-native'; +import _ from 'underscore'; import styles from '../styles/styles'; import Header from './Header'; import Navigation from '../libs/Navigation/Navigation'; @@ -18,6 +19,7 @@ import withKeyboardState, {keyboardStatePropTypes} from './withKeyboardState'; import AvatarWithDisplayName from './AvatarWithDisplayName'; import iouReportPropTypes from '../pages/iouReportPropTypes'; import participantPropTypes from './participantPropTypes'; +import CONST from '../CONST'; import PressableWithoutFeedback from './Pressable/PressableWithoutFeedback'; import PinButton from './PinButton'; @@ -66,6 +68,12 @@ const propTypes = { left: PropTypes.number, }), + /** The anchor alignment of the menu */ + threeDotsAnchorAlignment: PropTypes.shape({ + horizontal: PropTypes.oneOf(_.values(CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL)), + vertical: PropTypes.oneOf(_.values(CONST.MODAL.ANCHOR_ORIGIN_VERTICAL)), + }), + /** Whether we should show a close button */ shouldShowCloseButton: PropTypes.bool, @@ -135,6 +143,10 @@ const defaultProps = { vertical: 0, horizontal: 0, }, + threeDotsAnchorAlignment: { + horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, + vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP, + }, }; class HeaderWithBackButton extends Component { @@ -233,6 +245,7 @@ class HeaderWithBackButton extends Component { menuItems={this.props.threeDotsMenuItems} onIconPress={this.props.onThreeDotsButtonPress} anchorPosition={this.props.threeDotsAnchorPosition} + anchorAlignment={this.props.threeDotsAnchorAlignment} /> )} diff --git a/src/components/KeyboardSpacer/index.android.js b/src/components/KeyboardSpacer/index.android.js index 8c549e056fe7..217fdbd9b18c 100644 --- a/src/components/KeyboardSpacer/index.android.js +++ b/src/components/KeyboardSpacer/index.android.js @@ -3,7 +3,7 @@ * view up with the keyboard allowing the user to see what they are typing. */ import React from 'react'; -import {StatusBar} from 'react-native'; +import StatusBar from '../../libs/StatusBar'; import BaseKeyboardSpacer from './BaseKeyboardSpacer'; import withWindowDimensions, {windowDimensionsPropTypes} from '../withWindowDimensions'; diff --git a/src/components/LHNOptionsList/OptionRowLHN.js b/src/components/LHNOptionsList/OptionRowLHN.js index 67a26a519801..75a6949c573f 100644 --- a/src/components/LHNOptionsList/OptionRowLHN.js +++ b/src/components/LHNOptionsList/OptionRowLHN.js @@ -22,6 +22,7 @@ import OfflineWithFeedback from '../OfflineWithFeedback'; import PressableWithSecondaryInteraction from '../PressableWithSecondaryInteraction'; import * as ReportActionContextMenu from '../../pages/home/report/ContextMenu/ReportActionContextMenu'; import * as ContextMenuActions from '../../pages/home/report/ContextMenu/ContextMenuActions'; +import * as OptionsListUtils from '../../libs/OptionsListUtils'; const propTypes = { /** Style for hovered state */ @@ -55,12 +56,6 @@ const defaultProps = { const OptionRowLHN = (props) => { const optionItem = SidebarUtils.getOptionData(props.reportID); - const isPinned = _.get(optionItem, 'isPinned', false); - const isUnread = _.get(optionItem, 'isUnread', false); - - React.useEffect(() => { - ReportActionContextMenu.hideContextMenu(false); - }, [isPinned, isUnread]); if (!optionItem) { return null; @@ -169,7 +164,7 @@ const OptionRowLHN = (props) => { props.isFocused ? StyleUtils.getBackgroundAndBorderStyle(focusedBackgroundColor) : undefined, hovered && !props.isFocused ? StyleUtils.getBackgroundAndBorderStyle(hoveredBackgroundColor) : undefined, ]} - shouldShowTooltip={!optionItem.isChatRoom && !optionItem.isArchivedRoom} + shouldShowTooltip={OptionsListUtils.shouldOptionShowTooltip(optionItem)} /> ))} diff --git a/src/components/MentionSuggestions.js b/src/components/MentionSuggestions.js index 4d03a0b2d584..48e1520d736a 100644 --- a/src/components/MentionSuggestions.js +++ b/src/components/MentionSuggestions.js @@ -117,6 +117,7 @@ const MentionSuggestions = (props) => { onSelect={props.onSelect} isSuggestionPickerLarge={props.isMentionPickerLarge} shouldIncludeReportRecipientLocalTimeHeight={props.shouldIncludeReportRecipientLocalTimeHeight} + accessibilityLabelExtractor={keyExtractor} /> ); }; diff --git a/src/components/MenuItem.js b/src/components/MenuItem.js index e672c68b6133..77b4d559701d 100644 --- a/src/components/MenuItem.js +++ b/src/components/MenuItem.js @@ -76,7 +76,7 @@ const MenuItem = (props) => { [ styles.flexShrink1, styles.popoverMenuText, - props.icon ? styles.ml3 : undefined, + props.icon && !_.isArray(props.icon) ? styles.ml3 : undefined, props.shouldShowBasicTitle ? undefined : styles.textStrong, props.interactive && props.disabled ? {...styles.disabledText, ...styles.userSelectNone} : undefined, styles.pre, @@ -88,7 +88,7 @@ const MenuItem = (props) => { ); const descriptionTextStyle = StyleUtils.combineStyles([ styles.textLabelSupporting, - props.icon ? styles.ml3 : undefined, + props.icon && !_.isArray(props.icon) ? styles.ml3 : undefined, styles.lineHeightNormal, props.title ? descriptionVerticalMargin : undefined, props.descriptionTextStyle, @@ -125,90 +125,110 @@ const MenuItem = (props) => { > {({hovered, pressed}) => ( <> - - {Boolean(props.icon) && ( - - {props.iconType === CONST.ICON_TYPE_ICON && ( - - )} - {props.iconType === CONST.ICON_TYPE_WORKSPACE && ( - - )} - {props.iconType === CONST.ICON_TYPE_AVATAR && ( - - )} + + {Boolean(props.label) && ( + + {props.label} )} - - {Boolean(props.description) && props.shouldShowDescriptionOnTop && ( - - {props.description} - + + {Boolean(props.icon) && _.isArray(props.icon) && ( + )} - - {Boolean(props.title) && ( + {Boolean(props.icon) && !_.isArray(props.icon) && ( + + {props.iconType === CONST.ICON_TYPE_ICON && ( + + )} + {props.iconType === CONST.ICON_TYPE_WORKSPACE && ( + + )} + {props.iconType === CONST.ICON_TYPE_AVATAR && ( + + )} + + )} + + {Boolean(props.description) && props.shouldShowDescriptionOnTop && ( - {convertToLTR(props.title)} + {props.description} )} - {Boolean(props.shouldShowTitleIcon) && ( - + + {Boolean(props.title) && ( + + {convertToLTR(props.title)} + + )} + {Boolean(props.shouldShowTitleIcon) && ( + + + + )} + + {Boolean(props.description) && !props.shouldShowDescriptionOnTop && ( + + {props.description} + + )} + {Boolean(props.furtherDetails) && ( + + + {props.furtherDetails} + )} - {Boolean(props.description) && !props.shouldShowDescriptionOnTop && ( - - {props.description} - - )} - {Boolean(props.furtherDetails) && ( - - - - {props.furtherDetails} - - - )} diff --git a/src/components/Modal/index.web.js b/src/components/Modal/index.web.js index a4c396e5efdb..00a208db4f85 100644 --- a/src/components/Modal/index.web.js +++ b/src/components/Modal/index.web.js @@ -4,6 +4,7 @@ import BaseModal from './BaseModal'; import {propTypes, defaultProps} from './modalPropTypes'; import * as StyleUtils from '../../styles/StyleUtils'; import themeColors from '../../styles/themes/default'; +import StatusBar from '../../libs/StatusBar'; const Modal = (props) => { const setStatusBarColor = (color = themeColors.appBG) => { @@ -11,11 +12,7 @@ const Modal = (props) => { return; } - // Change the color of the status bar to align with the modal's backdrop (refer to https://github.com/Expensify/App/issues/12156). - const element = document.querySelector('meta[name=theme-color]'); - if (element) { - element.content = color; - } + StatusBar.setBackgroundColor(color); }; const hideModal = () => { diff --git a/src/components/MoneyRequestConfirmationList.js b/src/components/MoneyRequestConfirmationList.js index 3fca7f714be7..f8006788bbac 100755 --- a/src/components/MoneyRequestConfirmationList.js +++ b/src/components/MoneyRequestConfirmationList.js @@ -166,7 +166,6 @@ function MoneyRequestConfirmationList(props) { data: [formattedPayeePersonalDetails], shouldShow: true, indexOffset: 0, - isDisabled: true, }, { title: translate('moneyRequestConfirmationList.whoWasThere'), @@ -296,7 +295,6 @@ function MoneyRequestConfirmationList(props) { selectedOptions={selectedOptions} canSelectMultipleOptions={canModifyParticipants} disableArrowKeysActions={!canModifyParticipants} - isDisabled={!canModifyParticipants} boldStyle shouldTextInputAppearBelowOptions shouldShowTextInput={false} diff --git a/src/components/Onfido/index.css b/src/components/Onfido/index.css index ebc2089d9cd5..07cc81e1306c 100644 --- a/src/components/Onfido/index.css +++ b/src/components/Onfido/index.css @@ -10,6 +10,12 @@ position: relative; } +/* This is needed to dsiable the blue outline that shows up once the payment page is opened through keyboard */ +#onfido-mount [tabindex="-1"]:focus-visible, +#onfido-mount [tabindex="-1"]:focus[data-focusvisible-polyfill] { + box-shadow: none !important; +} + @media only screen and (max-width: 600px) { .onfido-sdk-ui-Modal-inner { /* This keeps the bottom of the Onfido window from being cut off on mobile web because the height was being diff --git a/src/components/OnyxProvider.js b/src/components/OnyxProvider.js index 6cee7e5b7a62..76cda71da2b2 100644 --- a/src/components/OnyxProvider.js +++ b/src/components/OnyxProvider.js @@ -11,7 +11,7 @@ const [withPersonalDetails, PersonalDetailsProvider] = createOnyxContext(ONYXKEY const [withCurrentDate, CurrentDateProvider] = createOnyxContext(ONYXKEYS.CURRENT_DATE); const [withReportActionsDrafts, ReportActionsDraftsProvider] = createOnyxContext(ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS); const [withBlockedFromConcierge, BlockedFromConciergeProvider] = createOnyxContext(ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE); -const [withBetas, BetasProvider] = createOnyxContext(ONYXKEYS.BETAS); +const [withBetas, BetasProvider, BetasContext] = createOnyxContext(ONYXKEYS.BETAS); const propTypes = { /** Rendered child component */ @@ -29,4 +29,4 @@ OnyxProvider.propTypes = propTypes; export default OnyxProvider; -export {withNetwork, withPersonalDetails, withReportActionsDrafts, withCurrentDate, withBlockedFromConcierge, withBetas, NetworkContext}; +export {withNetwork, withPersonalDetails, withReportActionsDrafts, withCurrentDate, withBlockedFromConcierge, withBetas, NetworkContext, BetasContext}; diff --git a/src/components/OptionRow.js b/src/components/OptionRow.js index 453e28a6271e..28ee50ccf8a1 100644 --- a/src/components/OptionRow.js +++ b/src/components/OptionRow.js @@ -20,6 +20,7 @@ import OfflineWithFeedback from './OfflineWithFeedback'; import CONST from '../CONST'; import * as ReportUtils from '../libs/ReportUtils'; import PressableWithFeedback from './Pressable/PressableWithFeedback'; +import * as OptionsListUtils from '../libs/OptionsListUtils'; const propTypes = { /** Style for hovered state */ @@ -67,7 +68,7 @@ const defaultProps = { isSelected: false, boldStyle: false, showTitleTooltip: false, - onSelectRow: () => {}, + onSelectRow: undefined, isDisabled: false, optionIsFocused: false, style: null, @@ -150,6 +151,10 @@ class OptionRow extends Component { (pressableRef = el)} onPress={(e) => { + if (!this.props.onSelectRow) { + return; + } + this.setState({isDisabled: true}); if (e) { e.preventDefault(); @@ -171,6 +176,7 @@ class OptionRow extends Component { this.props.shouldDisableRowInnerPadding ? null : styles.sidebarLinkInner, this.props.optionIsFocused ? styles.sidebarLinkActive : null, this.props.shouldHaveOptionSeparator && styles.borderTop, + !this.props.onSelectRow && !this.props.isDisabled ? styles.cursorDefault : null, ]} accessibilityLabel={this.props.option.text} accessibilityRole="button" @@ -198,7 +204,7 @@ class OptionRow extends Component { this.props.optionIsFocused ? StyleUtils.getBackgroundAndBorderStyle(focusedBackgroundColor) : undefined, hovered && !this.props.optionIsFocused ? StyleUtils.getBackgroundAndBorderStyle(hoveredBackgroundColor) : undefined, ]} - shouldShowTooltip={this.props.showTitleTooltip && !this.props.option.isChatRoom && !this.props.option.isArchivedRoom} + shouldShowTooltip={this.props.showTitleTooltip && OptionsListUtils.shouldOptionShowTooltip(this.props.option)} /> ))} diff --git a/src/components/OptionsList/optionsListPropTypes.js b/src/components/OptionsList/optionsListPropTypes.js index 915d84e058c3..e6e507f4da09 100644 --- a/src/components/OptionsList/optionsListPropTypes.js +++ b/src/components/OptionsList/optionsListPropTypes.js @@ -88,7 +88,7 @@ const defaultProps = { hideSectionHeaders: false, disableFocusOptions: false, boldStyle: false, - onSelectRow: () => {}, + onSelectRow: undefined, headerMessage: '', innerRef: null, showTitleTooltip: false, diff --git a/src/components/OptionsSelector/BaseOptionsSelector.js b/src/components/OptionsSelector/BaseOptionsSelector.js index df15ad71febc..337674012106 100755 --- a/src/components/OptionsSelector/BaseOptionsSelector.js +++ b/src/components/OptionsSelector/BaseOptionsSelector.js @@ -305,7 +305,7 @@ class BaseOptionsSelector extends Component { (this.list = el)} optionHoveredStyle={this.props.optionHoveredStyle} - onSelectRow={this.selectRow} + onSelectRow={this.props.onSelectRow ? this.selectRow : undefined} sections={this.props.sections} focusedIndex={this.state.focusedIndex} selectedOptions={this.props.selectedOptions} diff --git a/src/components/OptionsSelector/optionsSelectorPropTypes.js b/src/components/OptionsSelector/optionsSelectorPropTypes.js index bc7436e3dba8..02b807bf66c1 100644 --- a/src/components/OptionsSelector/optionsSelectorPropTypes.js +++ b/src/components/OptionsSelector/optionsSelectorPropTypes.js @@ -109,7 +109,7 @@ const propTypes = { }; const defaultProps = { - onSelectRow: () => {}, + onSelectRow: undefined, textInputLabel: '', placeholderText: '', keyboardType: 'default', diff --git a/src/components/PopoverMenu/index.js b/src/components/PopoverMenu/index.js index 17982c0d5684..330a6a8b61f5 100644 --- a/src/components/PopoverMenu/index.js +++ b/src/components/PopoverMenu/index.js @@ -64,7 +64,7 @@ const PopoverMenu = (props) => { return ( { diff --git a/src/components/Pressable/GenericPressable/BaseGenericPressable.js b/src/components/Pressable/GenericPressable/BaseGenericPressable.js index 6cdb83bbb81a..0b15378de8bc 100644 --- a/src/components/Pressable/GenericPressable/BaseGenericPressable.js +++ b/src/components/Pressable/GenericPressable/BaseGenericPressable.js @@ -135,7 +135,7 @@ const GenericPressable = forwardRef((props, ref) => { return ( (this.pressableRef = el)} // eslint-disable-next-line react/jsx-props-no-spreading {...defaultPressableProps} + style={(state) => [StyleUtils.parseStyleFromFunction(this.props.style, state), ...[this.props.inline && styles.dInline]]} > {this.props.children} diff --git a/src/components/QRShare/index.js b/src/components/QRShare/index.js index 0a942c951d9d..5dc2c53dc8d3 100644 --- a/src/components/QRShare/index.js +++ b/src/components/QRShare/index.js @@ -1,5 +1,6 @@ import React, {Component} from 'react'; import {View} from 'react-native'; +import _ from 'underscore'; import withLocalize, {withLocalizePropTypes} from '../withLocalize'; import defaultTheme from '../../styles/themes/default'; import styles from '../../styles/styles'; @@ -73,7 +74,7 @@ class QRShare extends Component { {this.props.title} - {this.props.subtitle && ( + {!_.isEmpty(this.props.subtitle) && ( { const isChatRoom = ReportUtils.isChatRoom(props.report); const isDefault = !(isChatRoom || isPolicyExpenseChat); const participants = lodashGet(props.report, 'participants', []); + const participantAccountIDs = lodashGet(props.report, 'participantAccountIDs', []); const isMultipleParticipant = participants.length > 1; const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips(OptionsListUtils.getPersonalDetailsForLogins(participants, props.personalDetails), isMultipleParticipant); const roomWelcomeMessage = ReportUtils.getRoomWelcomeMessage(props.report); @@ -97,7 +98,7 @@ const ReportWelcomeText = (props) => { Navigation.navigate(ROUTES.getDetailsRoute(participants[index]))} + onPress={() => Navigation.navigate(ROUTES.getProfileRoute(participantAccountIDs[index]))} > {displayName} diff --git a/src/components/TaskSelectorLink.js b/src/components/TaskSelectorLink.js deleted file mode 100644 index f495049a74fd..000000000000 --- a/src/components/TaskSelectorLink.js +++ /dev/null @@ -1,115 +0,0 @@ -import React from 'react'; -// eslint-disable-next-line no-restricted-imports -import {View, TouchableOpacity} from 'react-native'; -import PropTypes from 'prop-types'; -import styles from '../styles/styles'; -import Icon from './Icon'; -import * as Expensicons from './Icon/Expensicons'; -import themeColors from '../styles/themes/default'; -import variables from '../styles/variables'; -import Text from './Text'; -import withLocalize, {withLocalizePropTypes} from './withLocalize'; -import * as StyleUtils from '../styles/StyleUtils'; -import DisplayNames from './DisplayNames'; -import MultipleAvatars from './MultipleAvatars'; -import CONST from '../CONST'; -import avatarPropTypes from './avatarPropTypes'; - -const propTypes = { - /** Array of avatar URLs or icons */ - icons: PropTypes.arrayOf(avatarPropTypes), - - /** The title to display */ - text: PropTypes.string, - - /** The description to display */ - alternateText: PropTypes.string, - - /** The function to call when the link is pressed */ - onPress: PropTypes.func.isRequired, - - /** Label for the Link */ - label: PropTypes.string.isRequired, - - /** Whether it is a share location */ - isShareDestination: PropTypes.bool, - - /** Whether the Touchable should be disabled */ - disabled: PropTypes.bool, - - /** Whether we're creating a new task or editing */ - isNewTask: PropTypes.bool, - - ...withLocalizePropTypes, -}; - -const defaultProps = { - icons: [], - text: '', - alternateText: '', - isShareDestination: false, - disabled: false, - isNewTask: true, -}; - -const TaskSelectorLink = (props) => { - const displayNameStyle = StyleUtils.combineStyles(styles.optionDisplayName, styles.pre); - const alternateTextStyle = StyleUtils.combineStyles(styles.sidebarLinkText, styles.optionAlternateText, styles.textLabelSupporting, styles.pre); - return ( - - - {props.icons.length !== 0 || props.text !== '' ? ( - - {props.translate(props.label)} - - - - - {props.alternateText ? ( - - {props.alternateText} - - ) : null} - - - - ) : ( - {props.translate(props.label)} - )} - {props.disabled || !props.isNewTask ? null : ( - - )} - - - ); -}; - -TaskSelectorLink.defaultProps = defaultProps; -TaskSelectorLink.propTypes = propTypes; -TaskSelectorLink.displayName = 'TaskSelectorLink'; - -export default withLocalize(TaskSelectorLink); diff --git a/src/components/ThreeDotsMenu/index.js b/src/components/ThreeDotsMenu/index.js index b395d4dcb388..5217ca8441a1 100644 --- a/src/components/ThreeDotsMenu/index.js +++ b/src/components/ThreeDotsMenu/index.js @@ -1,6 +1,7 @@ import React, {Component} from 'react'; import {View} from 'react-native'; import PropTypes from 'prop-types'; +import _ from 'underscore'; import Icon from '../Icon'; import PopoverMenu from '../PopoverMenu'; import styles from '../../styles/styles'; @@ -8,6 +9,7 @@ import withLocalize, {withLocalizePropTypes} from '../withLocalize'; import Tooltip from '../Tooltip'; import * as Expensicons from '../Icon/Expensicons'; import ThreeDotsMenuItemPropTypes from './ThreeDotsMenuItemPropTypes'; +import CONST from '../../CONST'; import PressableWithoutFeedback from '../Pressable/PressableWithoutFeedback'; const propTypes = { @@ -39,6 +41,12 @@ const propTypes = { bottom: PropTypes.number, left: PropTypes.number, }).isRequired, + + /** The anchor alignment of the menu */ + anchorAlignment: PropTypes.shape({ + horizontal: PropTypes.oneOf(_.values(CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL)), + vertical: PropTypes.oneOf(_.values(CONST.MODAL.ANCHOR_ORIGIN_VERTICAL)), + }), }; const defaultProps = { @@ -47,6 +55,10 @@ const defaultProps = { iconStyles: [], icon: Expensicons.ThreeDots, onIconPress: () => {}, + anchorAlignment: { + horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, + vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, + }, }; class ThreeDotsMenu extends Component { @@ -97,6 +109,7 @@ class ThreeDotsMenu extends Component { onClose={this.hidePopoverMenu} isVisible={this.state.isPopupMenuVisible} anchorPosition={this.props.anchorPosition} + anchorAlignment={this.props.anchorAlignment} onItemSelected={this.hidePopoverMenu} menuItems={this.props.menuItems} /> diff --git a/src/components/UnreadActionIndicator.js b/src/components/UnreadActionIndicator.js index 81cf266bef28..0d45c90aa441 100755 --- a/src/components/UnreadActionIndicator.js +++ b/src/components/UnreadActionIndicator.js @@ -10,7 +10,7 @@ const UnreadActionIndicator = (props) => ( accessibilityLabel={props.translate('accessibilityHints.newMessageLineIndicator')} data-action-id={props.reportActionID} style={[styles.unreadIndicatorContainer, styles.userSelectNone]} - nativeID={CONST.UNREAD_ACTION_INDICATOR_ID} + dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}} > {props.translate('common.new')} diff --git a/src/components/ValidateCode/AbracadabraModal.js b/src/components/ValidateCode/AbracadabraModal.js deleted file mode 100644 index 8debd439bf64..000000000000 --- a/src/components/ValidateCode/AbracadabraModal.js +++ /dev/null @@ -1,47 +0,0 @@ -import React, {PureComponent} from 'react'; -import {View} from 'react-native'; -import colors from '../../styles/colors'; -import styles from '../../styles/styles'; -import Icon from '../Icon'; -import withLocalize, {withLocalizePropTypes} from '../withLocalize'; -import Text from '../Text'; -import * as Expensicons from '../Icon/Expensicons'; -import * as Illustrations from '../Icon/Illustrations'; -import variables from '../../styles/variables'; - -const propTypes = { - ...withLocalizePropTypes, -}; - -class AbracadabraModal extends PureComponent { - render() { - return ( - - - - - - {this.props.translate('validateCodeModal.successfulSignInTitle')} - - {this.props.translate('validateCodeModal.successfulSignInDescription')} - - - - - - - ); - } -} - -AbracadabraModal.propTypes = propTypes; -export default withLocalize(AbracadabraModal); diff --git a/src/components/ValidateCode/JustSignedInModal.js b/src/components/ValidateCode/JustSignedInModal.js new file mode 100644 index 000000000000..40b25e4a19e0 --- /dev/null +++ b/src/components/ValidateCode/JustSignedInModal.js @@ -0,0 +1,53 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import {View} from 'react-native'; +import colors from '../../styles/colors'; +import styles from '../../styles/styles'; +import Icon from '../Icon'; +import withLocalize, {withLocalizePropTypes} from '../withLocalize'; +import Text from '../Text'; +import * as Expensicons from '../Icon/Expensicons'; +import * as Illustrations from '../Icon/Illustrations'; +import variables from '../../styles/variables'; + +const propTypes = { + ...withLocalizePropTypes, + + /** Whether the 2FA is needed to get fully authenticated. */ + is2FARequired: PropTypes.bool.isRequired, +}; + +function JustSignedInModal(props) { + return ( + + + + + + + {props.translate(props.is2FARequired ? 'validateCodeModal.tfaRequiredTitle' : 'validateCodeModal.successfulSignInTitle')} + + + + {props.translate(props.is2FARequired ? 'validateCodeModal.tfaRequiredDescription' : 'validateCodeModal.successfulSignInDescription')} + + + + + + + + ); +} + +JustSignedInModal.propTypes = propTypes; +export default withLocalize(JustSignedInModal); diff --git a/src/components/ValidateCode/TfaRequiredModal.js b/src/components/ValidateCode/TfaRequiredModal.js deleted file mode 100644 index a4afb79c84b2..000000000000 --- a/src/components/ValidateCode/TfaRequiredModal.js +++ /dev/null @@ -1,47 +0,0 @@ -import React, {PureComponent} from 'react'; -import {View} from 'react-native'; -import colors from '../../styles/colors'; -import styles from '../../styles/styles'; -import Icon from '../Icon'; -import withLocalize, {withLocalizePropTypes} from '../withLocalize'; -import Text from '../Text'; -import * as Expensicons from '../Icon/Expensicons'; -import * as Illustrations from '../Icon/Illustrations'; -import variables from '../../styles/variables'; - -const propTypes = { - ...withLocalizePropTypes, -}; - -class TfaRequiredModal extends PureComponent { - render() { - return ( - - - - - - {this.props.translate('validateCodeModal.tfaRequiredTitle')} - - {this.props.translate('validateCodeModal.tfaRequiredDescription')} - - - - - - - ); - } -} - -TfaRequiredModal.propTypes = propTypes; -export default withLocalize(TfaRequiredModal); diff --git a/src/components/menuItemPropTypes.js b/src/components/menuItemPropTypes.js index 57266831377f..3909f446c907 100644 --- a/src/components/menuItemPropTypes.js +++ b/src/components/menuItemPropTypes.js @@ -22,7 +22,7 @@ const propTypes = { onPress: PropTypes.func, /** Icon to display on the left side of component */ - icon: PropTypes.oneOfType([PropTypes.elementType, PropTypes.string]), + icon: PropTypes.oneOfType([PropTypes.elementType, PropTypes.string, PropTypes.arrayOf(avatarPropTypes)]), /** Icon Width */ iconWidth: PropTypes.number, @@ -33,6 +33,9 @@ const propTypes = { /** Text to display for the item */ title: PropTypes.string.isRequired, + /** Text that appears above the title */ + label: PropTypes.string, + /** Boolean whether to display the title right icon */ shouldShowTitleIcon: PropTypes.bool, diff --git a/src/components/withLocalize.js b/src/components/withLocalize.js index 4cbdda876231..def7110c1b40 100755 --- a/src/components/withLocalize.js +++ b/src/components/withLocalize.js @@ -179,4 +179,4 @@ export default function withLocalize(WrappedComponent) { return WithLocalize; } -export {withLocalizePropTypes, Provider as LocaleContextProvider}; +export {withLocalizePropTypes, Provider as LocaleContextProvider, LocaleContext}; diff --git a/src/hooks/useLocalize.js b/src/hooks/useLocalize.js new file mode 100644 index 000000000000..9ad5048729bd --- /dev/null +++ b/src/hooks/useLocalize.js @@ -0,0 +1,6 @@ +import {useContext} from 'react'; +import {LocaleContext} from '../components/withLocalize'; + +export default function useLocalize() { + return useContext(LocaleContext); +} diff --git a/src/hooks/usePermissions.js b/src/hooks/usePermissions.js new file mode 100644 index 000000000000..4ab581231489 --- /dev/null +++ b/src/hooks/usePermissions.js @@ -0,0 +1,21 @@ +import _ from 'underscore'; +import {useContext, useMemo} from 'react'; +import Permissions from '../libs/Permissions'; +import {BetasContext} from '../components/OnyxProvider'; + +export default function usePermissions() { + const betas = useContext(BetasContext); + return useMemo( + () => + _.reduce( + Permissions, + (memo, checkerFunction, beta) => { + // eslint-disable-next-line no-param-reassign + memo[beta] = checkerFunction(betas); + return memo; + }, + {}, + ), + [betas], + ); +} diff --git a/src/languages/en.js b/src/languages/en.js index 8526b4aa4ec3..a2c372393cc9 100755 --- a/src/languages/en.js +++ b/src/languages/en.js @@ -44,6 +44,7 @@ export default { and: 'and', details: 'Details', privacy: 'Privacy', + hidden: 'Hidden', delete: 'Delete', archived: 'archived', contacts: 'Contacts', @@ -1074,6 +1075,7 @@ export default { growlMessageOnDeleteError: 'This workspace cannot be deleted right now because reports are actively being processed', unavailable: 'Unavailable workspace', memberNotFound: 'Member not found. To invite a new member to the workspace, please use the Invite button above.', + notAuthorized: `You do not have access to this page. Are you trying to join the workspace? Please reach out to the owner of this workspace so they can add you as a member! Something else? Reach out to ${CONST.EMAIL.CONCIERGE}`, goToRoom: ({roomName}) => `Go to ${roomName} room`, }, emptyWorkspace: { diff --git a/src/languages/es.js b/src/languages/es.js index e47d54cc7756..3ba86d87ec0c 100644 --- a/src/languages/es.js +++ b/src/languages/es.js @@ -43,6 +43,7 @@ export default { and: 'y', details: 'Detalles', privacy: 'Privacidad', + hidden: 'Oculto', delete: 'Eliminar', archived: 'archivado', contacts: 'Contactos', @@ -1079,6 +1080,7 @@ export default { growlMessageOnDeleteError: 'No se puede eliminar el espacio de trabajo porque tiene informes que están siendo procesados', unavailable: 'Espacio de trabajo no disponible', memberNotFound: 'Miembro no encontrado. Para invitar a un nuevo miembro al espacio de trabajo, por favor, utiliza el botón Invitar que está arriba.', + notAuthorized: `No tienes acceso a esta página. ¿Estás tratando de unirte al espacio de trabajo? Comunícate con el propietario de este espacio de trabajo para que pueda agregarte como miembro. ¿Necesitas algo más? Comunícate con ${CONST.EMAIL.CONCIERGE}`, goToRoom: ({roomName}) => `Ir a la sala ${roomName}`, }, emptyWorkspace: { diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js index ab4753a67c09..b983ffd14968 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js @@ -131,6 +131,16 @@ const DetailsModalStackNavigator = createModalStackNavigator([ }, ]); +const ProfileModalStackNavigator = createModalStackNavigator([ + { + getComponent: () => { + const ProfilePage = require('../../../pages/ProfilePage').default; + return ProfilePage; + }, + name: 'Profile_Root', + }, +]); + const ReportDetailsModalStackNavigator = createModalStackNavigator([ { getComponent: () => { @@ -223,8 +233,8 @@ const ReportParticipantsModalStackNavigator = createModalStackNavigator([ }, { getComponent: () => { - const DetailsPage = require('../../../pages/DetailsPage').default; - return DetailsPage; + const ProfilePage = require('../../../pages/ProfilePage').default; + return ProfilePage; }, name: 'ReportParticipants_Details', }, @@ -725,6 +735,7 @@ export { IOUSendModalStackNavigator, SplitDetailsModalStackNavigator, DetailsModalStackNavigator, + ProfileModalStackNavigator, ReportDetailsModalStackNavigator, TaskModalStackNavigator, ReportSettingsModalStackNavigator, diff --git a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.js b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.js index 2f5ac9267a50..cb82795936c2 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.js +++ b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.js @@ -34,6 +34,11 @@ function RigthModalNavigator() { options={defaultModalScreenOptions} component={ModalStackNavigators.DetailsModalStackNavigator} /> + ( options={defaultScreenOptions} component={UnlinkLoginPage} /> - ); diff --git a/src/libs/Navigation/linkingConfig.js b/src/libs/Navigation/linkingConfig.js index 666506ca1ab8..37506c0460ce 100644 --- a/src/libs/Navigation/linkingConfig.js +++ b/src/libs/Navigation/linkingConfig.js @@ -9,7 +9,6 @@ export default { initialRouteName: SCREENS.HOME, screens: { // Main Routes - SetPassword: ROUTES.SET_PASSWORD_WITH_VALIDATE_CODE, ValidateLogin: ROUTES.VALIDATE_LOGIN, UnlinkLogin: ROUTES.UNLINK_LOGIN, [SCREENS.TRANSITION_FROM_OLD_DOT]: ROUTES.TRANSITION_FROM_OLD_DOT, @@ -278,6 +277,11 @@ export default { Details_Root: ROUTES.DETAILS, }, }, + Profile: { + screens: { + Profile_Root: ROUTES.PROFILE, + }, + }, Participants: { screens: { ReportParticipants_Root: ROUTES.REPORT_PARTICIPANTS, diff --git a/src/libs/OptionsListUtils.js b/src/libs/OptionsListUtils.js index ced088836927..7d1dd8592e67 100644 --- a/src/libs/OptionsListUtils.js +++ b/src/libs/OptionsListUtils.js @@ -419,7 +419,7 @@ function createOption(logins, personalDetails, report, reportActions = {}, {show result.isMoneyRequestReport = ReportUtils.isMoneyRequestReport(report); result.isThread = ReportUtils.isThread(report); result.isTaskReport = ReportUtils.isTaskReport(report); - result.shouldShowSubscript = result.isPolicyExpenseChat && !report.isOwnPolicyExpenseChat && !result.isArchivedRoom; + result.shouldShowSubscript = ReportUtils.shouldReportShowSubscript(report); result.allReportErrors = getAllReportErrors(report, reportActions); result.brickRoadIndicator = !_.isEmpty(result.allReportErrors) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''; result.pendingAction = report.pendingFields ? report.pendingFields.addWorkspaceRoom || report.pendingFields.createChat : null; @@ -950,6 +950,15 @@ function getHeaderMessage(hasSelectableOptions, hasUserToInvite, searchValue, ma return ''; } +/** + * Helper method to check whether an option can show tooltip or not + * @param {Object} option + * @returns {Boolean} + */ +function shouldOptionShowTooltip(option) { + return (!option.isChatRoom || option.isThread) && !option.isArchivedRoom; +} + export { addSMSDomainIfPhoneNumber, getAvatarsForLogins, @@ -968,4 +977,5 @@ export { getPolicyExpenseReportOptions, getParticipantsOptions, isSearchStringMatch, + shouldOptionShowTooltip, }; diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js index d27ee3610813..fa8b93741aa3 100644 --- a/src/libs/ReportUtils.js +++ b/src/libs/ReportUtils.js @@ -1057,14 +1057,14 @@ function getReport(reportID) { * @param {Object} report */ function navigateToDetailsPage(report) { - const participants = lodashGet(report, 'participants', []); + const participantAccountIDs = lodashGet(report, 'participantAccountIDs', []); if (isChatRoom(report) || isPolicyExpenseChat(report) || isThread(report)) { Navigation.navigate(ROUTES.getReportDetailsRoute(report.reportID)); return; } - if (participants.length === 1) { - Navigation.navigate(ROUTES.getDetailsRoute(participants[0])); + if (participantAccountIDs.length === 1) { + Navigation.navigate(ROUTES.getProfileRoute(participantAccountIDs[0])); return; } Navigation.navigate(ROUTES.getReportParticipantsRoute(report.reportID)); @@ -2124,7 +2124,7 @@ function shouldReportShowSubscript(report) { return false; } - if (isPolicyExpenseChat(report) && !report.isOwnPolicyExpenseChat) { + if (isPolicyExpenseChat(report) && !isThread(report) && !isTaskReport(report) && !report.isOwnPolicyExpenseChat) { return true; } diff --git a/src/libs/SelectionScraper/index.js b/src/libs/SelectionScraper/index.js index aab5ca78e4ed..44b87deba796 100644 --- a/src/libs/SelectionScraper/index.js +++ b/src/libs/SelectionScraper/index.js @@ -83,10 +83,12 @@ const getHTMLOfSelection = () => { // Find and remove the div housing the UnreadActionIndicator because we don't want // the 'New/Nuevo' text inside it being copied. - const newMessageLineIndicatorDiv = div.querySelector(`#${CONST.UNREAD_ACTION_INDICATOR_ID}`); + const divsToRemove = div.querySelectorAll(`[data-${CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT}=true]`); - if (newMessageLineIndicatorDiv) { - newMessageLineIndicatorDiv.remove(); + if (divsToRemove && divsToRemove.length > 0) { + divsToRemove.forEach((element) => { + element.remove(); + }); } return div.innerHTML; diff --git a/src/libs/StatusBar/index.android.js b/src/libs/StatusBar/index.android.js index 0ea4480dc928..45b6d9626c9f 100644 --- a/src/libs/StatusBar/index.android.js +++ b/src/libs/StatusBar/index.android.js @@ -1,4 +1,8 @@ +// eslint-disable-next-line no-restricted-imports import {StatusBar} from 'react-native'; +// Only has custom web implementation +StatusBar.getBackgroundColor = () => null; + // Just export StatusBar – no changes. export default StatusBar; diff --git a/src/libs/StatusBar/index.js b/src/libs/StatusBar/index.js index 4aea200cab00..ef15d597f93e 100644 --- a/src/libs/StatusBar/index.js +++ b/src/libs/StatusBar/index.js @@ -1,6 +1,11 @@ +// eslint-disable-next-line no-restricted-imports import {StatusBar} from 'react-native'; -// Overwrite setTranslucent to suppress a warning on iOS +// Only has custom web implementation +StatusBar.getBackgroundColor = () => null; + +// Overwrite setTranslucent and setBackgroundColor suppress warnings on iOS StatusBar.setTranslucent = () => {}; +StatusBar.setBackgroundColor = () => {}; export default StatusBar; diff --git a/src/libs/StatusBar/index.web.js b/src/libs/StatusBar/index.web.js new file mode 100644 index 000000000000..dfa1226c33a8 --- /dev/null +++ b/src/libs/StatusBar/index.web.js @@ -0,0 +1,20 @@ +// eslint-disable-next-line no-restricted-imports +import {StatusBar} from 'react-native'; + +StatusBar.getBackgroundColor = () => { + const element = document.querySelector('meta[name=theme-color]'); + if (!element || !element.content) { + return null; + } + return element.content; +}; + +StatusBar.setBackgroundColor = (backgroundColor) => { + const element = document.querySelector('meta[name=theme-color]'); + if (!element) { + return; + } + element.content = backgroundColor; +}; + +export default StatusBar; diff --git a/src/libs/actions/PersonalDetails.js b/src/libs/actions/PersonalDetails.js index fc47ca5142f6..b362436def5c 100644 --- a/src/libs/actions/PersonalDetails.js +++ b/src/libs/actions/PersonalDetails.js @@ -310,6 +310,49 @@ function openPersonalDetailsPage() { API.read('OpenPersonalDetailsPage'); } +/** + * Fetches public profile info about a given user. + * The API will only return the accountID, displayName, and avatar for the user + * but the profile page will use other info (e.g. contact methods and pronouns) if they are already available in Onyx + * @param {Number} accountID + */ +function openPublicProfilePage(accountID) { + const optimisticData = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + value: { + [accountID]: { + isLoading: true, + }, + }, + }, + ]; + const successData = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + value: { + [accountID]: { + isLoading: false, + }, + }, + }, + ]; + const failureData = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + value: { + [accountID]: { + isLoading: false, + }, + }, + }, + ]; + API.read('OpenPublicProfilePage', {accountID}, {optimisticData, successData, failureData}); +} + /** * Updates the user's avatar image * @@ -427,6 +470,7 @@ export { deleteAvatar, openMoneyRequestModalPage, openPersonalDetailsPage, + openPublicProfilePage, extractFirstAndLastNameFromAvailableDetails, updateDisplayName, updateLegalName, diff --git a/src/libs/actions/Report.js b/src/libs/actions/Report.js index c1ec6266bb18..a88cccebeaaa 100644 --- a/src/libs/actions/Report.js +++ b/src/libs/actions/Report.js @@ -383,10 +383,6 @@ function openReport(reportID, participantList = [], newReportObject = {}, parent // and we need data to be available when we navigate to the chat page if (_.isEmpty(ReportUtils.getReport(reportID))) { optimisticReportData.onyxMethod = Onyx.METHOD.SET; - optimisticReportData.value = { - ...optimisticReportData.value, - reportID: reportID.toString(), - }; } // If we are creating a new report, we need to add the optimistic report data and a report action diff --git a/src/libs/actions/Session/index.js b/src/libs/actions/Session/index.js index 9cb3c127b287..b53d7811ef8d 100644 --- a/src/libs/actions/Session/index.js +++ b/src/libs/actions/Session/index.js @@ -197,45 +197,6 @@ function resendValidateCode(login = credentials.login) { API.write('RequestNewValidateCode', {email: login}, {optimisticData, successData, failureData}); } -/** - * Request a new validate / magic code for user to sign in automatically with the link - * - * @param {String} [login] - */ -function resendLinkWithValidateCode(login = credentials.login) { - const optimisticData = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.ACCOUNT, - value: { - isLoading: true, - message: null, - }, - }, - ]; - const successData = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.ACCOUNT, - value: { - isLoading: false, - message: Localize.translateLocal('validateCodeModal.successfulNewCodeRequest'), - }, - }, - ]; - const failureData = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.ACCOUNT, - value: { - isLoading: false, - message: null, - }, - }, - ]; - API.write('RequestNewValidateCode', {email: login}, {optimisticData, successData, failureData}); -} - /** * Checks the API to see if an account exists for the given login * @@ -768,7 +729,7 @@ function requestUnlinkValidationLink() { key: ONYXKEYS.ACCOUNT, value: { isLoading: false, - message: Localize.translateLocal('unlinkLoginForm.linkSent'), + message: 'unlinkLoginForm.linkSent', loadingForm: null, }, }, @@ -926,7 +887,6 @@ export { signOutAndRedirectToSignIn, resendValidationLink, resendValidateCode, - resendLinkWithValidateCode, resetPassword, resendResetPassword, requestUnlinkValidationLink, diff --git a/src/libs/actions/Task.js b/src/libs/actions/Task.js index ec150a332155..59ea33e0d722 100644 --- a/src/libs/actions/Task.js +++ b/src/libs/actions/Task.js @@ -297,7 +297,9 @@ function editTaskAndNavigate(report, ownerEmail, title, description, assignee) { let assigneeChatReportID; if (assignee && assignee !== report.managerEmail) { assigneeChatReportID = ReportUtils.getChatByParticipants([assignee]).reportID; - optimisticAssigneeAddComment = ReportUtils.buildOptimisticTaskCommentReportAction(report.reportID, reportName, assignee, `Assigned a task to you: ${reportName}`); + if (assigneeChatReportID !== report.parentReportID.toString()) { + optimisticAssigneeAddComment = ReportUtils.buildOptimisticTaskCommentReportAction(report.reportID, reportName, assignee, `Assigned a task to you: ${reportName}`); + } } const optimisticData = [ diff --git a/src/pages/ProfilePage.js b/src/pages/ProfilePage.js new file mode 100755 index 000000000000..932f489be5f4 --- /dev/null +++ b/src/pages/ProfilePage.js @@ -0,0 +1,240 @@ +import {View, ScrollView} from 'react-native'; +import React, {useEffect} from 'react'; +import PropTypes from 'prop-types'; +import _ from 'underscore'; +import {withOnyx} from 'react-native-onyx'; +import Str from 'expensify-common/lib/str'; +import lodashGet from 'lodash/get'; +import {parsePhoneNumber} from 'awesome-phonenumber'; +import styles from '../styles/styles'; +import Text from '../components/Text'; +import ONYXKEYS from '../ONYXKEYS'; +import Avatar from '../components/Avatar'; +import HeaderWithBackButton from '../components/HeaderWithBackButton'; +import Navigation from '../libs/Navigation/Navigation'; +import ScreenWrapper from '../components/ScreenWrapper'; +import personalDetailsPropType from './personalDetailsPropType'; +import withLocalize, {withLocalizePropTypes} from '../components/withLocalize'; +import compose from '../libs/compose'; +import CommunicationsLink from '../components/CommunicationsLink'; +import Tooltip from '../components/Tooltip'; +import CONST from '../CONST'; +import * as ReportUtils from '../libs/ReportUtils'; +import * as Expensicons from '../components/Icon/Expensicons'; +import MenuItem from '../components/MenuItem'; +import AttachmentModal from '../components/AttachmentModal'; +import PressableWithoutFocus from '../components/PressableWithoutFocus'; +import * as Report from '../libs/actions/Report'; +import OfflineWithFeedback from '../components/OfflineWithFeedback'; +import AutoUpdateTime from '../components/AutoUpdateTime'; +import * as UserUtils from '../libs/UserUtils'; +import * as PersonalDetails from '../libs/actions/PersonalDetails'; +import FullScreenLoadingIndicator from '../components/FullscreenLoadingIndicator'; +import BlockingView from '../components/BlockingViews/BlockingView'; +import * as Illustrations from '../components/Icon/Illustrations'; +import variables from '../styles/variables'; +import ROUTES from '../ROUTES'; + +const matchType = PropTypes.shape({ + params: PropTypes.shape({ + /** accountID passed via route /a/:accountID */ + accountID: PropTypes.string, + + /** report ID passed */ + reportID: PropTypes.string, + }), +}); + +const propTypes = { + /* Onyx Props */ + + /** The personal details of all users */ + personalDetails: PropTypes.objectOf(personalDetailsPropType), + + /** Route params */ + route: matchType.isRequired, + + /** Login list for the user that is signed in */ + loginList: PropTypes.shape({ + /** Phone/Email associated with user */ + partnerUserID: PropTypes.string, + }), + + ...withLocalizePropTypes, +}; + +const defaultProps = { + // When opening someone else's profile (via deep link) before login, this is empty + personalDetails: {}, + loginList: {}, +}; + +/** + * Gets the phone number to display for SMS logins + * + * @param {Object} details + * @param {String} details.login + * @param {String} details.displayName + * @returns {String} + */ +const getPhoneNumber = (details) => { + // If the user hasn't set a displayName, it is set to their phone number, so use that + const displayName = lodashGet(details, 'displayName', ''); + const parsedPhoneNumber = parsePhoneNumber(displayName); + if (parsedPhoneNumber.possible) { + return parsedPhoneNumber.number.e164; + } + + // If the user has set a displayName, get the phone number from the SMS login + return details.login ? Str.removeSMSDomain(details.login) : ''; +}; + +function ProfilePage(props) { + const accountID = lodashGet(props.route.params, 'accountID', 0); + + // eslint-disable-next-line rulesdir/prefer-early-return + useEffect(() => { + if (accountID > 0) { + PersonalDetails.openPublicProfilePage(accountID); + } + }, [accountID]); + + const details = lodashGet(props.personalDetails, accountID, {}); + const displayName = details.displayName ? details.displayName : props.translate('common.hidden'); + const avatar = lodashGet(details, 'avatar', UserUtils.getDefaultAvatar()); + const originalFileName = lodashGet(details, 'originalFileName', ''); + const login = lodashGet(details, 'login', ''); + const timezone = lodashGet(details, 'timezone', {}); + + // If we have a reportID param this means that we + // arrived here via the ParticipantsPage and should be allowed to navigate back to it + const shouldShowLocalTime = !ReportUtils.hasAutomatedExpensifyEmails([login]) && !_.isEmpty(timezone); + + let pronouns = lodashGet(details, 'pronouns', ''); + if (pronouns && pronouns.startsWith(CONST.PRONOUNS.PREFIX)) { + const localeKey = pronouns.replace(CONST.PRONOUNS.PREFIX, ''); + pronouns = props.translate(`pronouns.${localeKey}`); + } + + const isSMSLogin = Str.isSMSLogin(login); + const phoneNumber = getPhoneNumber(details); + const phoneOrEmail = isSMSLogin ? getPhoneNumber(details) : login; + + const isCurrentUser = _.keys(props.loginList).includes(login); + const hasMinimumDetails = !_.isEmpty(details.avatar); + const isLoading = lodashGet(details, 'isLoading', false) || _.isEmpty(details); + + // If the API returns an error for some reason there won't be any details and isLoading will get set to false, so we want to show a blocking screen + const shouldShowBlockingView = !hasMinimumDetails && !isLoading; + + return ( + + Navigation.goBack(ROUTES.HOME)} + /> + + {hasMinimumDetails && ( + + + + {({show}) => ( + + + + + + )} + + {Boolean(displayName) && ( + + {displayName} + + )} + {login ? ( + + + {props.translate(isSMSLogin ? 'common.phoneNumber' : 'common.email')} + + + + {isSMSLogin ? props.formatPhoneNumber(phoneNumber) : login} + + + + ) : null} + {pronouns ? ( + + + {props.translate('profilePage.preferredPronouns')} + + {pronouns} + + ) : null} + {shouldShowLocalTime && } + + {!isCurrentUser && Boolean(login) && ( + Report.navigateToAndOpenReport([login])} + wrapperStyle={styles.breakAll} + shouldShowRightIcon + /> + )} + + )} + {!hasMinimumDetails && isLoading && } + {shouldShowBlockingView && ( + + )} + + + ); +} + +ProfilePage.propTypes = propTypes; +ProfilePage.defaultProps = defaultProps; +ProfilePage.displayName = 'ProfilePage'; + +export default compose( + withLocalize, + withOnyx({ + personalDetails: { + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + }, + loginList: { + key: ONYXKEYS.LOGIN_LIST, + }, + }), +)(ProfilePage); diff --git a/src/pages/ReimbursementAccount/ReimbursementAccountPage.js b/src/pages/ReimbursementAccount/ReimbursementAccountPage.js index a7cf6f4d4a1b..7e18a7edbdc2 100644 --- a/src/pages/ReimbursementAccount/ReimbursementAccountPage.js +++ b/src/pages/ReimbursementAccount/ReimbursementAccountPage.js @@ -343,16 +343,6 @@ class ReimbursementAccountPage extends React.Component { ); } - if (this.state.shouldShowContinueSetupButton) { - return ( - - ); - } - let errorComponent; const userHasPhonePrimaryEmail = Str.endsWith(this.props.session.email, CONST.SMS.DOMAIN); @@ -386,6 +376,16 @@ class ReimbursementAccountPage extends React.Component { ); } + if (this.state.shouldShowContinueSetupButton) { + return ( + + ); + } + if (currentStep === CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT) { return ( { return { alternateText: userLogin, displayName: userPersonalDetail.displayName, + accountID: userPersonalDetail.accountID, icons: [ { source: UserUtils.getAvatar(userPersonalDetail.avatar, login), @@ -109,7 +110,7 @@ const ReportParticipantsPage = (props) => { }, ]} onSelectRow={(option) => { - Navigation.navigate(ROUTES.getReportParticipantRoute(props.route.params.reportID, option.login)); + Navigation.navigate(ROUTES.getReportParticipantRoute(props.route.params.reportID, option.accountID)); }} hideSectionHeaders showTitleTooltip diff --git a/src/pages/ReportWelcomeMessagePage.js b/src/pages/ReportWelcomeMessagePage.js index 185eeab446d5..6d7a69f8de92 100644 --- a/src/pages/ReportWelcomeMessagePage.js +++ b/src/pages/ReportWelcomeMessagePage.js @@ -43,7 +43,7 @@ function ReportWelcomeMessagePage(props) { }, []); const submitForm = useCallback(() => { - Report.updateWelcomeMessage(props.report.reportID, props.report.welcomeMessage, welcomeMessage); + Report.updateWelcomeMessage(props.report.reportID, props.report.welcomeMessage, welcomeMessage.trim()); }, [props.report.reportID, props.report.welcomeMessage, welcomeMessage]); return ( diff --git a/src/pages/SetPasswordPage.js b/src/pages/SetPasswordPage.js deleted file mode 100755 index f00a7095e463..000000000000 --- a/src/pages/SetPasswordPage.js +++ /dev/null @@ -1,137 +0,0 @@ -import React, {Component} from 'react'; -import {View} from 'react-native'; -import {SafeAreaView} from 'react-native-safe-area-context'; -import PropTypes from 'prop-types'; -import {withOnyx} from 'react-native-onyx'; -import _ from 'underscore'; -import lodashGet from 'lodash/get'; -import {propTypes as validateLinkPropTypes, defaultProps as validateLinkDefaultProps} from './ValidateLoginPage/validateLinkPropTypes'; -import styles from '../styles/styles'; -import * as Session from '../libs/actions/Session'; -import ONYXKEYS from '../ONYXKEYS'; -import SignInPageLayout from './signin/SignInPageLayout'; -import withLocalize, {withLocalizePropTypes} from '../components/withLocalize'; -import compose from '../libs/compose'; -import NewPasswordForm from './settings/NewPasswordForm'; -import FormAlertWithSubmitButton from '../components/FormAlertWithSubmitButton'; -import FormSubmit from '../components/FormSubmit'; -import * as ErrorUtils from '../libs/ErrorUtils'; - -const propTypes = { - /* Onyx Props */ - - /** The details about the account that the user is signing in with */ - account: PropTypes.shape({ - /** An error message to display to the user */ - errors: PropTypes.objectOf(PropTypes.string), - - /** Whether a sign on form is loading (being submitted) */ - isLoading: PropTypes.bool, - }), - - /** The credentials of the logged in person */ - credentials: PropTypes.shape({ - /** The email the user logged in with */ - login: PropTypes.string, - - /** The password used to log in the user */ - password: PropTypes.string, - }), - - /** Session object */ - session: PropTypes.shape({ - /** An error message to display to the user */ - errors: PropTypes.objectOf(PropTypes.string), - }), - - /** The accountID and validateCode are passed via the URL */ - route: validateLinkPropTypes, - - ...withLocalizePropTypes, -}; - -const defaultProps = { - account: {}, - credentials: {}, - route: validateLinkDefaultProps, - session: { - errors: null, - authToken: '', - }, -}; - -class SetPasswordPage extends Component { - constructor(props) { - super(props); - - this.validateAndSubmitForm = this.validateAndSubmitForm.bind(this); - - this.state = { - password: '', - isFormValid: false, - }; - } - - componentWillUnmount() { - Session.clearAccountMessages(); - } - - validateAndSubmitForm() { - if (!this.state.isFormValid) { - return; - } - const accountID = lodashGet(this.props.route.params, 'accountID', ''); - const validateCode = lodashGet(this.props.route.params, 'validateCode', ''); - Session.updatePasswordAndSignin(accountID, validateCode, this.state.password); - } - - render() { - const buttonText = this.props.translate('setPasswordPage.setPassword'); - const error = ErrorUtils.getLatestErrorMessage(this.props.account) || ErrorUtils.getLatestErrorMessage(this.props.session); - return ( - - - - - this.setState({password})} - updateIsFormValid={(isValid) => this.setState({isFormValid: isValid})} - /> - - - - - - - - ); - } -} - -SetPasswordPage.propTypes = propTypes; -SetPasswordPage.defaultProps = defaultProps; - -export default compose( - withLocalize, - withOnyx({ - credentials: {key: ONYXKEYS.CREDENTIALS}, - account: {key: ONYXKEYS.ACCOUNT}, - session: { - key: ONYXKEYS.SESSION, - initWithStoredValues: false, - }, - }), -)(SetPasswordPage); diff --git a/src/pages/ValidateLoginPage/index.website.js b/src/pages/ValidateLoginPage/index.website.js index 6dfc5d8d87b7..26bfdf902a22 100644 --- a/src/pages/ValidateLoginPage/index.website.js +++ b/src/pages/ValidateLoginPage/index.website.js @@ -11,12 +11,11 @@ import ONYXKEYS from '../../ONYXKEYS'; import * as Session from '../../libs/actions/Session'; import Permissions from '../../libs/Permissions'; import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize'; -import AbracadabraModal from '../../components/ValidateCode/AbracadabraModal'; import ExpiredValidateCodeModal from '../../components/ValidateCode/ExpiredValidateCodeModal'; import Navigation from '../../libs/Navigation/Navigation'; import ROUTES from '../../ROUTES'; import CONST from '../../CONST'; -import TfaRequiredModal from '../../components/ValidateCode/TfaRequiredModal'; +import JustSignedInModal from '../../components/ValidateCode/JustSignedInModal'; const propTypes = { /** The accountID and validateCode are passed via the URL */ @@ -124,8 +123,8 @@ class ValidateLoginPage extends Component { return ( <> {currentAuthState === CONST.AUTO_AUTH_STATE.FAILED && } - {currentAuthState === CONST.AUTO_AUTH_STATE.JUST_SIGNED_IN && is2FARequired && !isSignedIn && } - {currentAuthState === CONST.AUTO_AUTH_STATE.JUST_SIGNED_IN && isSignedIn && } + {currentAuthState === CONST.AUTO_AUTH_STATE.JUST_SIGNED_IN && is2FARequired && !isSignedIn && } + {currentAuthState === CONST.AUTO_AUTH_STATE.JUST_SIGNED_IN && isSignedIn && } {currentAuthState === CONST.AUTO_AUTH_STATE.NOT_STARTED && !isSignedIn && ( { } const shouldShowThreeDotsButton = !!threeDotMenuItems.length; - const shouldShowSubscript = isPolicyExpenseChat && !props.report.isOwnPolicyExpenseChat && !ReportUtils.isArchivedRoom(props.report) && !isTaskReport; + const shouldShowSubscript = ReportUtils.shouldReportShowSubscript(props.report); const icons = ReportUtils.getIcons(reportHeaderData, props.personalDetails); const brickRoadIndicator = ReportUtils.hasReportNameError(props.report) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''; return ( @@ -158,7 +158,7 @@ const HeaderView = (props) => { ) : ( )} diff --git a/src/pages/home/report/ContextMenu/MiniReportActionContextMenu/index.js b/src/pages/home/report/ContextMenu/MiniReportActionContextMenu/index.js index f2883acc3b95..2c0f2ff65561 100644 --- a/src/pages/home/report/ContextMenu/MiniReportActionContextMenu/index.js +++ b/src/pages/home/report/ContextMenu/MiniReportActionContextMenu/index.js @@ -5,6 +5,7 @@ import PropTypes from 'prop-types'; import {propTypes as genericReportActionContextMenuPropTypes, defaultProps as GenericReportActionContextMenuDefaultProps} from '../genericReportActionContextMenuPropTypes'; import * as StyleUtils from '../../../../../styles/StyleUtils'; import BaseReportActionContextMenu from '../BaseReportActionContextMenu'; +import CONST from '../../../../../CONST'; const propTypes = { ..._.omit(genericReportActionContextMenuPropTypes, ['isMini']), @@ -20,7 +21,10 @@ const defaultProps = { }; const MiniReportActionContextMenu = (props) => ( - + { hoverStyle={styles.hoveredComponentBG} onSelectRow={() => { props.onClose(); - Navigation.navigate(ROUTES.getDetailsRoute(item.login)); + Navigation.navigate(ROUTES.getProfileRoute(item.accountID)); }} option={{ text: Str.removeSMSDomain(item.displayName), diff --git a/src/pages/home/report/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose.js index a7542d69d213..30ab83916f38 100644 --- a/src/pages/home/report/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose.js @@ -85,6 +85,9 @@ const propTypes = { /** Array of report actions for this report */ reportActions: PropTypes.arrayOf(PropTypes.shape(reportActionPropTypes)), + /** The actions from the parent report */ + parentReportActions: PropTypes.objectOf(PropTypes.shape(reportActionPropTypes)), + /** Is the window width narrow, like on a mobile device */ isSmallScreenWidth: PropTypes.bool.isRequired, @@ -125,6 +128,7 @@ const defaultProps = { modal: {}, report: {}, reportActions: [], + parentReportActions: {}, blockedFromConcierge: {}, personalDetails: {}, preferredSkinTone: CONST.EMOJI_DEFAULT_SKIN_TONE, @@ -829,7 +833,9 @@ class ReportActionCompose extends React.Component { ) { e.preventDefault(); - const lastReportAction = _.find(this.props.reportActions, (action) => ReportUtils.canEditReportAction(action)); + const parentReportActionID = lodashGet(this.props.report, 'parentReportActionID', ''); + const parentReportAction = lodashGet(this.props.parentReportActions, [parentReportActionID], {}); + const lastReportAction = _.find([...this.props.reportActions, parentReportAction], (action) => ReportUtils.canEditReportAction(action)); if (lastReportAction !== -1 && lastReportAction) { Report.saveReportActionDraft(this.props.reportID, lastReportAction.reportActionID, _.last(lastReportAction.message).html); @@ -1257,5 +1263,9 @@ export default compose( shouldShowComposeInput: { key: ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT, }, + parentReportActions: { + key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`, + canEvict: false, + }, }), )(ReportActionCompose); diff --git a/src/pages/home/report/ReportActionItemFragment.js b/src/pages/home/report/ReportActionItemFragment.js index d1aa0cf7ddac..e606b0c65f69 100644 --- a/src/pages/home/report/ReportActionItemFragment.js +++ b/src/pages/home/report/ReportActionItemFragment.js @@ -122,7 +122,6 @@ const ReportActionItemFragment = (props) => { return ( diff --git a/src/pages/home/report/ReportActionItemSingle.js b/src/pages/home/report/ReportActionItemSingle.js index 96fd6be6ad26..eb5f2fa07434 100644 --- a/src/pages/home/report/ReportActionItemSingle.js +++ b/src/pages/home/report/ReportActionItemSingle.js @@ -61,13 +61,13 @@ const defaultProps = { report: undefined, }; -const showUserDetails = (email) => { - Navigation.navigate(ROUTES.getDetailsRoute(email)); +const showUserDetails = (accountID) => { + Navigation.navigate(ROUTES.getProfileRoute(accountID)); }; const ReportActionItemSingle = (props) => { const actorEmail = props.action.actorEmail.replace(CONST.REGEX.MERGED_ACCOUNT_PREFIX, ''); - const {avatar, displayName, pendingFields} = props.personalDetails[actorEmail] || {}; + const {accountID, avatar, displayName, pendingFields} = props.personalDetails[actorEmail] || {}; const avatarSource = UserUtils.getAvatar(avatar, actorEmail); // Since the display name for a report action message is delivered with the report history as an array of fragments @@ -88,7 +88,7 @@ const ReportActionItemSingle = (props) => { style={[styles.alignSelfStart, styles.mr3]} onPressIn={ControlSelection.block} onPressOut={ControlSelection.unblock} - onPress={() => showUserDetails(actorEmail)} + onPress={() => showUserDetails(accountID)} > {props.shouldShowSubscriptAvatar ? ( @@ -118,7 +118,7 @@ const ReportActionItemSingle = (props) => { style={[styles.flexShrink1, styles.mr1]} onPressIn={ControlSelection.block} onPressOut={ControlSelection.unblock} - onPress={() => showUserDetails(actorEmail)} + onPress={() => showUserDetails(accountID)} > {_.map(personArray, (fragment, index) => ( { +function MoneyRequestParticipantsPage(props) { if (props.iou.loading) { return ( @@ -67,7 +67,7 @@ const MoneyRequestParticipantsPage = (props) => { iouType={props.iouType} /> ); -}; +} MoneyRequestParticipantsPage.displayName = 'IOUParticipantsPage'; MoneyRequestParticipantsPage.propTypes = propTypes; diff --git a/src/pages/settings/Payments/AddPayPalMePage.js b/src/pages/settings/Payments/AddPayPalMePage.js index 69a89ceb5f79..cbb437ea00e6 100644 --- a/src/pages/settings/Payments/AddPayPalMePage.js +++ b/src/pages/settings/Payments/AddPayPalMePage.js @@ -1,6 +1,5 @@ import React, {useRef, useState, useCallback} from 'react'; -// eslint-disable-next-line no-restricted-imports -import {View, TouchableWithoutFeedback, Linking} from 'react-native'; +import {View, Linking} from 'react-native'; import _ from 'underscore'; import CONST from '../../../CONST'; import ROUTES from '../../../ROUTES'; @@ -20,6 +19,7 @@ import * as User from '../../../libs/actions/User'; import Icon from '../../../components/Icon'; import * as Expensicons from '../../../components/Icon/Expensicons'; import variables from '../../../styles/variables'; +import PressableWithoutFeedback from '../../../components/Pressable/PressableWithoutFeedback'; const AddPayPalMePage = (props) => { const [payPalMeUsername, setPayPalMeUsername] = useState(''); @@ -69,8 +69,10 @@ const AddPayPalMePage = (props) => { /> {props.translate('addPayPalMePage.checkListOf')} - Linking.openURL('https://developer.paypal.com/docs/reports/reference/paypal-supported-currencies')} > @@ -89,7 +91,7 @@ const AddPayPalMePage = (props) => { /> - + diff --git a/src/pages/settings/Payments/PaymentMethodList.js b/src/pages/settings/Payments/PaymentMethodList.js index 4d9c3e62b33c..b6ffbf1e0f9d 100644 --- a/src/pages/settings/Payments/PaymentMethodList.js +++ b/src/pages/settings/Payments/PaymentMethodList.js @@ -1,5 +1,5 @@ import _ from 'underscore'; -import React, {Component} from 'react'; +import React, {useCallback, useMemo} from 'react'; import PropTypes from 'prop-types'; import {FlatList} from 'react-native'; import lodashGet from 'lodash/get'; @@ -87,47 +87,66 @@ const defaultProps = { listHeaderComponent: null, }; -class PaymentMethodList extends Component { - constructor(props) { - super(props); +/** + * Dismisses the error on the payment method + * @param {Object} item + */ +function dismissError(item) { + const paymentList = item.accountType === CONST.PAYMENT_METHODS.BANK_ACCOUNT ? ONYXKEYS.BANK_ACCOUNT_LIST : ONYXKEYS.CARD_LIST; + const paymentID = item.accountType === CONST.PAYMENT_METHODS.BANK_ACCOUNT ? lodashGet(item, ['accountData', 'bankAccountID'], '') : lodashGet(item, ['accountData', 'fundID'], ''); + + if (!paymentID) { + Log.info('Unable to clear payment method error: ', item); + return; + } - this.renderItem = this.renderItem.bind(this); + if (item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + PaymentMethods.clearDeletePaymentMethodError(paymentList, paymentID); + } else { + PaymentMethods.clearAddPaymentMethodError(paymentList, paymentID); } +} - /** - * @param {Boolean} isDefault - * @returns {*} - */ - getDefaultBadgeText(isDefault = false) { - if (!isDefault) { - return null; - } +/** + * @param {Array} filteredPaymentMethods + * @param {Boolean} isDefault + * @returns {Boolean} + */ +function shouldShowDefaultBadge(filteredPaymentMethods, isDefault = false) { + if (!isDefault) { + return false; + } - const defaultablePaymentMethodCount = _.reduce( - this.getFilteredPaymentMethods(), - (count, method) => (method.accountType === CONST.PAYMENT_METHODS.BANK_ACCOUNT || method.accountType === CONST.PAYMENT_METHODS.DEBIT_CARD ? count + 1 : count), - 0, - ); - if (defaultablePaymentMethodCount <= 1) { - return null; - } + const defaultablePaymentMethodCount = _.filter( + filteredPaymentMethods, + (method) => method.accountType === CONST.PAYMENT_METHODS.BANK_ACCOUNT || method.accountType === CONST.PAYMENT_METHODS.DEBIT_CARD, + ).length; + return defaultablePaymentMethodCount > 1; +} - return this.props.translate('paymentMethodList.defaultPaymentMethod'); - } +/** + * @param {String} actionPaymentMethodType + * @param {String|Number} activePaymentMethodID + * @param {String} paymentMethod + * @return {Boolean} + */ +function isPaymentMethodActive(actionPaymentMethodType, activePaymentMethodID, paymentMethod) { + return paymentMethod.accountType === actionPaymentMethodType && paymentMethod.methodID === activePaymentMethodID; +} +function PaymentMethodList(props) { + const {actionPaymentMethodType, activePaymentMethodID, bankAccountList, cardList, filterType, network, onPress, payPalMeData, shouldShowSelectedState, selectedMethodID, translate} = + props; - /** - * @returns {Array} - */ - getFilteredPaymentMethods() { + const filteredPaymentMethods = useMemo(() => { // Hide any billing cards that are not P2P debit cards for now because you cannot make them your default method, or delete them - const filteredCardList = _.filter(this.props.cardList, (card) => card.accountData.additionalData.isP2PDebitCard); - let combinedPaymentMethods = PaymentUtils.formatPaymentMethods(this.props.bankAccountList, filteredCardList, this.props.payPalMeData); + const filteredCardList = _.filter(cardList, (card) => card.accountData.additionalData.isP2PDebitCard); + let combinedPaymentMethods = PaymentUtils.formatPaymentMethods(bankAccountList, filteredCardList, payPalMeData); - if (!_.isEmpty(this.props.filterType)) { - combinedPaymentMethods = _.filter(combinedPaymentMethods, (paymentMethod) => paymentMethod.accountType === this.props.filterType); + if (!_.isEmpty(filterType)) { + combinedPaymentMethods = _.filter(combinedPaymentMethods, (paymentMethod) => paymentMethod.accountType === filterType); } - if (!this.props.network.isOffline) { + if (!network.isOffline) { combinedPaymentMethods = _.filter( combinedPaymentMethods, (paymentMethod) => paymentMethod.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || !_.isEmpty(paymentMethod.errors), @@ -136,44 +155,23 @@ class PaymentMethodList extends Component { combinedPaymentMethods = _.map(combinedPaymentMethods, (paymentMethod) => ({ ...paymentMethod, - onPress: (e) => this.props.onPress(e, paymentMethod.accountType, paymentMethod.accountData, paymentMethod.isDefault, paymentMethod.methodID), - iconFill: this.isPaymentMethodActive(paymentMethod) ? StyleUtils.getIconFillColor(CONST.BUTTON_STATES.PRESSED) : null, - wrapperStyle: this.isPaymentMethodActive(paymentMethod) ? [StyleUtils.getButtonBackgroundColorStyle(CONST.BUTTON_STATES.PRESSED)] : null, + onPress: (e) => onPress(e, paymentMethod.accountType, paymentMethod.accountData, paymentMethod.isDefault, paymentMethod.methodID), + iconFill: isPaymentMethodActive(actionPaymentMethodType, activePaymentMethodID, paymentMethod) ? StyleUtils.getIconFillColor(CONST.BUTTON_STATES.PRESSED) : null, + wrapperStyle: isPaymentMethodActive(actionPaymentMethodType, activePaymentMethodID, paymentMethod) + ? [StyleUtils.getButtonBackgroundColorStyle(CONST.BUTTON_STATES.PRESSED)] + : null, disabled: paymentMethod.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, })); return combinedPaymentMethods; - } - - /** - * Dismisses the error on the payment method - * @param {Object} item - */ - dismissError(item) { - const paymentList = item.accountType === CONST.PAYMENT_METHODS.BANK_ACCOUNT ? ONYXKEYS.BANK_ACCOUNT_LIST : ONYXKEYS.CARD_LIST; - const paymentID = item.accountType === CONST.PAYMENT_METHODS.BANK_ACCOUNT ? lodashGet(item, ['accountData', 'bankAccountID'], '') : lodashGet(item, ['accountData', 'fundID'], ''); - - if (!paymentID) { - Log.info('Unable to clear payment method error: ', item); - return; - } - - if (item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { - PaymentMethods.clearDeletePaymentMethodError(paymentList, paymentID); - } else { - PaymentMethods.clearAddPaymentMethodError(paymentList, paymentID); - } - } + }, [actionPaymentMethodType, activePaymentMethodID, bankAccountList, cardList, filterType, network, onPress, payPalMeData]); /** - * @param {Object} paymentMethod - * @param {String|Number} paymentMethod.methodID - * @param {String} paymentMethod.accountType - * @return {Boolean} + * Render placeholder when there are no payments methods + * + * @return {React.Component} */ - isPaymentMethodActive(paymentMethod) { - return paymentMethod.accountType === this.props.actionPaymentMethodType && paymentMethod.methodID === this.props.activePaymentMethodID; - } + const renderListEmptyComponent = useCallback(() => {translate('paymentMethodList.addFirstPaymentMethod')}, [translate]); /** * Create a menuItem for each passed paymentMethod @@ -183,10 +181,10 @@ class PaymentMethodList extends Component { * * @return {React.Component} */ - renderItem({item}) { - return ( + const renderItem = useCallback( + ({item}) => ( this.dismissError(item)} + onClose={() => dismissError(item)} pendingAction={item.pendingAction} errors={item.errors} errorRowStyles={styles.ph6} @@ -200,59 +198,50 @@ class PaymentMethodList extends Component { iconFill={item.iconFill} iconHeight={item.iconSize} iconWidth={item.iconSize} - badgeText={this.getDefaultBadgeText(item.isDefault)} + badgeText={shouldShowDefaultBadge(filteredPaymentMethods, item.isDefault) ? translate('paymentMethodList.defaultPaymentMethod') : null} wrapperStyle={item.wrapperStyle} - shouldShowSelectedState={this.props.shouldShowSelectedState} - isSelected={this.props.selectedMethodID === item.methodID} + shouldShowSelectedState={shouldShowSelectedState} + isSelected={selectedMethodID === item.methodID} /> - ); - } - - /** - * Show add first payment copy when payment methods are - * - * @return {React.Component} - */ - renderListEmptyComponent() { - return {this.props.translate('paymentMethodList.addFirstPaymentMethod')}; - } - - render() { - return ( - <> - item.key} - ListEmptyComponent={this.renderListEmptyComponent()} - ListHeaderComponent={this.props.listHeaderComponent} - /> - {this.props.shouldShowAddPaymentMethodButton && ( - - {(isOffline) => ( -