diff --git a/android/app/build.gradle b/android/app/build.gradle index dbb3fcb14959..12f144c4daa8 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 1001033504 - versionName "1.3.35-4" + versionCode 1001033602 + versionName "1.3.36-2" } splits { diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index acb4af5102bb..867e9778ddba 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.3.35 + 1.3.36 CFBundleSignature ???? CFBundleURLTypes @@ -32,7 +32,7 @@ CFBundleVersion - 1.3.35.4 + 1.3.36.2 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index c5be730448c5..e9f95bc6a1dc 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.3.35 + 1.3.36 CFBundleSignature ???? CFBundleVersion - 1.3.35.4 + 1.3.36.2 diff --git a/package-lock.json b/package-lock.json index b1414ce2b9c3..0b3570ddff3b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.3.35-4", + "version": "1.3.36-2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.3.35-4", + "version": "1.3.36-2", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -74,7 +74,7 @@ "react-native-fast-image": "^8.6.3", "react-native-fs": "^2.20.0", "react-native-gesture-handler": "2.9.0", - "react-native-google-places-autocomplete": "git+https://github.com/Expensify/react-native-google-places-autocomplete.git#6f436a06a3018cb49750bb110b89df75f6a865d5", + "react-native-google-places-autocomplete": "git+https://github.com/Expensify/react-native-google-places-autocomplete.git#ee87343c3e827ff7818abc71b6bb04fcc1f120e0", "react-native-haptic-feedback": "^1.13.0", "react-native-image-pan-zoom": "^2.1.12", "react-native-image-picker": "^5.1.0", @@ -152,7 +152,7 @@ "css-loader": "^6.7.2", "diff-so-fancy": "^1.3.0", "dotenv": "^16.0.3", - "electron": "22.3.7", + "electron": "22.3.14", "electron-builder": "24.5.0", "eslint": "^7.6.0", "eslint-config-expensify": "^2.0.38", @@ -22321,9 +22321,9 @@ } }, "node_modules/electron": { - "version": "22.3.7", - "resolved": "https://registry.npmjs.org/electron/-/electron-22.3.7.tgz", - "integrity": "sha512-QUuRCl0QJk0w2yPAQXl6sk4YV1b9353w4e1eO/fF2OUmrGQV9Fy2pEpEDV1PIq/JJ/oeVVlI3H07LHpEcNb0TA==", + "version": "22.3.14", + "resolved": "https://registry.npmjs.org/electron/-/electron-22.3.14.tgz", + "integrity": "sha512-WxVcLnC4DrkBLN1/BwpxNkGvVq8iq1hM7lae5nvjnSYg/bwVbuo1Cwc80Keft4MIWKlYCXNiKKqs3qCXV4Aiaw==", "dev": true, "hasInstallScript": true, "dependencies": { @@ -36899,8 +36899,8 @@ }, "node_modules/react-native-google-places-autocomplete": { "version": "2.5.1", - "resolved": "git+ssh://git@github.com/Expensify/react-native-google-places-autocomplete.git#6f436a06a3018cb49750bb110b89df75f6a865d5", - "integrity": "sha512-7NiBK83VggJ2HQaHGfJoaPyxtiLu1chwP1VqH9te+PZtf0L9p50IuBQciW+4s173cBamt4U2+mvnCt7zfMFeDg==", + "resolved": "git+ssh://git@github.com/Expensify/react-native-google-places-autocomplete.git#ee87343c3e827ff7818abc71b6bb04fcc1f120e0", + "integrity": "sha512-OJWCz4Epj1p8tyNImWNykAqpd/X1MkNCFPY0dSbgiTJGbW4J5T4bC0PIUQ+ExjxWpWjcFaielTLdoSz0HfeIpw==", "license": "MIT", "dependencies": { "lodash.debounce": "^4.0.8", @@ -59059,9 +59059,9 @@ } }, "electron": { - "version": "22.3.7", - "resolved": "https://registry.npmjs.org/electron/-/electron-22.3.7.tgz", - "integrity": "sha512-QUuRCl0QJk0w2yPAQXl6sk4YV1b9353w4e1eO/fF2OUmrGQV9Fy2pEpEDV1PIq/JJ/oeVVlI3H07LHpEcNb0TA==", + "version": "22.3.14", + "resolved": "https://registry.npmjs.org/electron/-/electron-22.3.14.tgz", + "integrity": "sha512-WxVcLnC4DrkBLN1/BwpxNkGvVq8iq1hM7lae5nvjnSYg/bwVbuo1Cwc80Keft4MIWKlYCXNiKKqs3qCXV4Aiaw==", "dev": true, "requires": { "@electron/get": "^2.0.0", @@ -68890,9 +68890,9 @@ } }, "react-native-google-places-autocomplete": { - "version": "git+ssh://git@github.com/Expensify/react-native-google-places-autocomplete.git#6f436a06a3018cb49750bb110b89df75f6a865d5", - "integrity": "sha512-7NiBK83VggJ2HQaHGfJoaPyxtiLu1chwP1VqH9te+PZtf0L9p50IuBQciW+4s173cBamt4U2+mvnCt7zfMFeDg==", - "from": "react-native-google-places-autocomplete@git+https://github.com/Expensify/react-native-google-places-autocomplete.git#6f436a06a3018cb49750bb110b89df75f6a865d5", + "version": "git+ssh://git@github.com/Expensify/react-native-google-places-autocomplete.git#ee87343c3e827ff7818abc71b6bb04fcc1f120e0", + "integrity": "sha512-OJWCz4Epj1p8tyNImWNykAqpd/X1MkNCFPY0dSbgiTJGbW4J5T4bC0PIUQ+ExjxWpWjcFaielTLdoSz0HfeIpw==", + "from": "react-native-google-places-autocomplete@git+https://github.com/Expensify/react-native-google-places-autocomplete.git#ee87343c3e827ff7818abc71b6bb04fcc1f120e0", "requires": { "lodash.debounce": "^4.0.8", "prop-types": "^15.7.2", diff --git a/package.json b/package.json index 3d293377ec9f..2c026bd0a570 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.3.35-4", + "version": "1.3.36-2", "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.", @@ -113,7 +113,7 @@ "react-native-fast-image": "^8.6.3", "react-native-fs": "^2.20.0", "react-native-gesture-handler": "2.9.0", - "react-native-google-places-autocomplete": "git+https://github.com/Expensify/react-native-google-places-autocomplete.git#6f436a06a3018cb49750bb110b89df75f6a865d5", + "react-native-google-places-autocomplete": "git+https://github.com/Expensify/react-native-google-places-autocomplete.git#ee87343c3e827ff7818abc71b6bb04fcc1f120e0", "react-native-haptic-feedback": "^1.13.0", "react-native-image-pan-zoom": "^2.1.12", "react-native-image-picker": "^5.1.0", @@ -191,7 +191,7 @@ "css-loader": "^6.7.2", "diff-so-fancy": "^1.3.0", "dotenv": "^16.0.3", - "electron": "22.3.7", + "electron": "22.3.14", "electron-builder": "24.5.0", "eslint": "^7.6.0", "eslint-config-expensify": "^2.0.38", diff --git a/src/CONST.js b/src/CONST.js index b7d8e91da5a2..b5e3d30516bd 100755 --- a/src/CONST.js +++ b/src/CONST.js @@ -1118,7 +1118,6 @@ const CONST = { REGEX: { SPECIAL_CHARS_WITHOUT_NEWLINE: /((?!\n)[()-\s\t])/g, DIGITS_AND_PLUS: /^\+?[0-9]*$/, - ALPHABETIC_CHARS: /[a-zA-Z]+/, ALPHABETIC_CHARS_WITH_NUMBER: /^[a-zA-ZÀ-ÿ0-9 ]*$/, POSITIVE_INTEGER: /^\d+$/, PO_BOX: /\b[P|p]?(OST|ost)?\.?\s*[O|o|0]?(ffice|FFICE)?\.?\s*[B|b][O|o|0]?[X|x]?\.?\s+[#]?(\d+)\b/, diff --git a/src/ROUTES.js b/src/ROUTES.js index a5fd5b4188c3..a95a68964998 100644 --- a/src/ROUTES.js +++ b/src/ROUTES.js @@ -68,6 +68,8 @@ export default { getReportRoute: (reportID) => `r/${reportID}`, REPORT_WITH_ID_DETAILS_SHARE_CODE: 'r/:reportID/details/shareCode', getReportShareCodeRoute: (reportID) => `r/${reportID}/details/shareCode`, + REPORT_ATTACHMENTS: 'r/:reportID/attachment', + getReportAttachmentRoute: (reportID, source) => `r/${reportID}/attachment?source=${encodeURI(source)}`, SELECT_YEAR: 'select-year', getYearSelectionRoute: (minYear, maxYear, currYear, backTo) => `select-year?min=${minYear}&max=${maxYear}&year=${currYear}&backTo=${backTo}`, diff --git a/src/SCREENS.js b/src/SCREENS.js index 24ea27fe9689..aec13a490376 100644 --- a/src/SCREENS.js +++ b/src/SCREENS.js @@ -6,6 +6,7 @@ export default { HOME: 'Home', LOADING: 'Loading', REPORT: 'Report', + REPORT_ATTACHMENTS: 'ReportAttachments', NOT_FOUND: 'not-found', TRANSITION_FROM_OLD_DOT: 'TransitionFromOldDot', }; diff --git a/src/components/AddressSearch/index.js b/src/components/AddressSearch/index.js index 9699eb9aab94..795e45c6f892 100644 --- a/src/components/AddressSearch/index.js +++ b/src/components/AddressSearch/index.js @@ -120,7 +120,7 @@ function AddressSearch(props) { postal_code: zipCode, administrative_area_level_1: state, administrative_area_level_2: stateFallback, - country, + country: countryPrimary, } = GooglePlacesUtils.getAddressComponents(addressComponents, { street_number: 'long_name', route: 'long_name', @@ -142,7 +142,15 @@ function AddressSearch(props) { // Make sure that the order of keys remains such that the country is always set above the state. // Refer to https://github.com/Expensify/App/issues/15633 for more information. - const {state: stateAutoCompleteFallback = '', city: cityAutocompleteFallback = ''} = GooglePlacesUtils.getPlaceAutocompleteTerms(autocompleteData.terms); + const { + country: countryFallbackLongName = '', + state: stateAutoCompleteFallback = '', + city: cityAutocompleteFallback = '', + } = GooglePlacesUtils.getPlaceAutocompleteTerms(autocompleteData.terms); + + const countryFallback = _.findKey(CONST.ALL_COUNTRIES, (country) => country === countryFallbackLongName); + + const country = countryPrimary || countryFallback; const values = { street: `${streetNumber} ${streetName}`.trim(), diff --git a/src/components/AttachmentCarousel/index.js b/src/components/AttachmentCarousel/index.js index d25d87a3ba49..403915f32938 100644 --- a/src/components/AttachmentCarousel/index.js +++ b/src/components/AttachmentCarousel/index.js @@ -189,6 +189,9 @@ class AttachmentCarousel extends React.Component { throw new Error('Attachment not found'); } + // Update the parent modal's state with the source and name from the mapped attachments + this.props.onNavigate(attachments[page]); + return { page, attachments, diff --git a/src/components/AttachmentModal.js b/src/components/AttachmentModal.js index a481a4026659..9e699bfe1ae3 100755 --- a/src/components/AttachmentModal.js +++ b/src/components/AttachmentModal.js @@ -37,6 +37,9 @@ const propTypes = { /** Optional callback to fire when we want to preview an image and approve it for use. */ onConfirm: PropTypes.func, + /** Whether the modal should be open by default */ + defaultOpen: PropTypes.bool, + /** Optional callback to fire when we want to do something after modal show. */ onModalShow: PropTypes.func, @@ -47,7 +50,7 @@ const propTypes = { originalFileName: PropTypes.string, /** A function as a child to pass modal launching methods to */ - children: PropTypes.func.isRequired, + children: PropTypes.func, /** Whether source url requires authentication */ isAuthTokenRequired: PropTypes.bool, @@ -69,7 +72,9 @@ const propTypes = { const defaultProps = { source: '', onConfirm: null, + defaultOpen: false, originalFileName: '', + children: null, isAuthTokenRequired: false, allowDownload: false, headerTitle: null, @@ -79,7 +84,7 @@ const defaultProps = { }; function AttachmentModal(props) { - const [isModalOpen, setIsModalOpen] = useState(false); + const [isModalOpen, setIsModalOpen] = useState(props.defaultOpen); const [shouldLoadAttachment, setShouldLoadAttachment] = useState(false); const [isAttachmentInvalid, setIsAttachmentInvalid] = useState(false); const [isAuthTokenRequired] = useState(props.isAuthTokenRequired); @@ -343,10 +348,11 @@ function AttachmentModal(props) { shouldShowCancelButton={false} /> - {props.children({ - displayFileInModal: validateAndDisplayFileToUpload, - show: openModal, - })} + {props.children && + props.children({ + displayFileInModal: validateAndDisplayFileToUpload, + show: openModal, + })} ); } diff --git a/src/components/Avatar.js b/src/components/Avatar.js index 66a1b60c3cef..b59a8902eb13 100644 --- a/src/components/Avatar.js +++ b/src/components/Avatar.js @@ -73,9 +73,12 @@ function Avatar(props) { const isWorkspace = props.type === CONST.ICON_TYPE_WORKSPACE; const iconSize = StyleUtils.getAvatarSize(props.size); - const imageStyle = props.imageStyles ? [StyleUtils.getAvatarStyle(props.size), ...props.imageStyles, StyleUtils.getAvatarBorderRadius(props.size, props.type)] : undefined; + const imageStyle = + props.imageStyles && props.imageStyles.length + ? [StyleUtils.getAvatarStyle(props.size), ...props.imageStyles, StyleUtils.getAvatarBorderRadius(props.size, props.type)] + : [StyleUtils.getAvatarStyle(props.size), styles.noBorderRadius]; - const iconStyle = props.imageStyles ? [StyleUtils.getAvatarStyle(props.size), styles.bgTransparent, ...props.imageStyles] : undefined; + const iconStyle = props.imageStyles && props.imageStyles.length ? [StyleUtils.getAvatarStyle(props.size), styles.bgTransparent, ...props.imageStyles] : undefined; const iconFillColor = isWorkspace ? StyleUtils.getDefaultWorkspaceAvatarColor(props.name).fill : props.fill; const fallbackAvatar = isWorkspace ? ReportUtils.getDefaultWorkspaceAvatar(props.name) : props.fallbackIcon; @@ -101,11 +104,13 @@ function Avatar(props) { /> ) : ( - setImageError(true)} - /> + + setImageError(true)} + /> + )} ); diff --git a/src/components/AvatarWithDisplayName.js b/src/components/AvatarWithDisplayName.js index 7889dcb0b703..152eaadcf709 100644 --- a/src/components/AvatarWithDisplayName.js +++ b/src/components/AvatarWithDisplayName.js @@ -17,6 +17,9 @@ import compose from '../libs/compose'; import * as OptionsListUtils from '../libs/OptionsListUtils'; import Text from './Text'; import * as StyleUtils from '../styles/StyleUtils'; +import Navigation from '../libs/Navigation/Navigation'; +import ROUTES from '../ROUTES'; +import PressableWithoutFeedback from './Pressable/PressableWithoutFeedback'; const propTypes = { /** The report currently being looked at */ @@ -52,6 +55,7 @@ const defaultProps = { function AvatarWithDisplayName(props) { const title = props.isAnonymous ? props.report.displayName : ReportUtils.getDisplayNameForParticipant(props.report.ownerAccountID, true); const subtitle = ReportUtils.getChatRoomSubtitle(props.report); + const parentNavigationSubtitle = ReportUtils.getParentNavigationSubtitle(props.report); const isExpenseReport = ReportUtils.isExpenseReport(props.report); const icons = ReportUtils.getIcons(props.report, props.personalDetails, props.policies); const ownerPersonalDetails = OptionsListUtils.getPersonalDetailsForAccountIDs([props.report.ownerAccountID], props.personalDetails); @@ -88,6 +92,22 @@ function AvatarWithDisplayName(props) { textStyles={[props.isAnonymous ? styles.headerAnonymousFooter : styles.headerText, styles.pre]} shouldUseFullTitle={isExpenseReport || props.isAnonymous} /> + {!_.isEmpty(parentNavigationSubtitle) && ( + { + Navigation.navigate(ROUTES.getReportRoute(props.report.parentReportID)); + }} + accessibilityLabel={subtitle} + accessibilityRole="link" + > + + {parentNavigationSubtitle} + + + )} {!_.isEmpty(subtitle) && ( {({anchor, report, action, checkIfContextMenuActive}) => ( - { + const route = ROUTES.getReportAttachmentRoute(report.reportID, source); + Navigation.navigate(route); + }} + onLongPress={(event) => showContextMenuForReport(event, anchor, report.reportID, action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report))} + accessibilityRole="imagebutton" + accessibilityLabel={props.translate('accessibilityHints.viewAttachment')} > - {({show}) => ( - showContextMenuForReport(event, anchor, report.reportID, action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report))} - accessibilityRole="imagebutton" - accessibilityLabel={props.translate('accessibilityHints.viewAttachment')} - > - - - )} - + + )} ); diff --git a/src/components/HeaderWithBackButton.js b/src/components/HeaderWithBackButton.js index c612b82851f9..bb707b8f8873 100755 --- a/src/components/HeaderWithBackButton.js +++ b/src/components/HeaderWithBackButton.js @@ -96,9 +96,6 @@ const propTypes = { /** Whether we should show an avatar */ shouldShowAvatarWithDisplay: PropTypes.bool, - /** Parent report, if provided it will override props.report for AvatarWithDisplay */ - parentReport: iouReportPropTypes, - /** Report, if we're showing the details for one and using AvatarWithDisplay */ report: iouReportPropTypes, @@ -133,7 +130,6 @@ const defaultProps = { shouldShowBackButton: true, shouldShowAvatarWithDisplay: false, report: null, - parentReport: null, policies: {}, personalDetails: {}, guidesCallTaskID: '', @@ -192,7 +188,7 @@ class HeaderWithBackButton extends Component { )} {this.props.shouldShowAvatarWithDisplay && ( diff --git a/src/components/KYCWall/BaseKYCWall.js b/src/components/KYCWall/BaseKYCWall.js index 956e1fd425c9..1d5f0f4fe06e 100644 --- a/src/components/KYCWall/BaseKYCWall.js +++ b/src/components/KYCWall/BaseKYCWall.js @@ -69,7 +69,7 @@ class KYCWall extends React.Component { } return { - anchorPositionVertical: domRect.top - 150, + anchorPositionVertical: domRect.top - 8, anchorPositionHorizontal: domRect.left, }; } diff --git a/src/components/MentionSuggestions.js b/src/components/MentionSuggestions.js index e8fc3003bf56..1480d98d7899 100644 --- a/src/components/MentionSuggestions.js +++ b/src/components/MentionSuggestions.js @@ -65,7 +65,7 @@ function MentionSuggestions(props) { const renderSuggestionMenuItem = (item) => { const isIcon = item.text === CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT; const styledDisplayName = getStyledTextArray(item.text, props.prefix); - const styledHandle = getStyledTextArray(item.alternateText, props.prefix); + const styledHandle = item.text === item.alternateText ? '' : getStyledTextArray(item.alternateText, props.prefix); return ( @@ -95,14 +95,18 @@ function MentionSuggestions(props) { style={[styles.mentionSuggestionsText, styles.flex1]} numberOfLines={1} > - {_.map(styledHandle, ({text, isColored}, i) => ( - - {text} - - ))} + {_.map( + styledHandle, + ({text, isColored}, i) => + text !== '' && ( + + {text} + + ), + )} ); diff --git a/src/components/MenuItem.js b/src/components/MenuItem.js index 07538b7ec658..a59dd6d7b971 100644 --- a/src/components/MenuItem.js +++ b/src/components/MenuItem.js @@ -79,7 +79,7 @@ function MenuItem(props) { props.icon && !_.isArray(props.icon) ? styles.ml3 : undefined, props.shouldShowBasicTitle ? undefined : styles.textStrong, props.shouldShowHeaderTitle ? styles.textHeadlineH1 : undefined, - props.interactive && props.disabled ? {...styles.disabledText, ...styles.userSelectNone} : undefined, + props.interactive && props.disabled ? {...styles.userSelectNone} : undefined, styles.pre, styles.ltr, isDeleted ? styles.offlineFeedback.deleted : undefined, @@ -119,6 +119,7 @@ function MenuItem(props) { StyleUtils.getButtonBackgroundColorStyle(getButtonState(props.focused || hovered, pressed, props.success, props.disabled, props.interactive), true), (hovered || pressed) && props.hoverAndPressStyle, ...(_.isArray(props.wrapperStyle) ? props.wrapperStyle : [props.wrapperStyle]), + props.disabled && styles.buttonOpacityDisabled, ]} disabled={props.disabled} ref={props.forwardedRef} diff --git a/src/components/MoneyRequestHeader.js b/src/components/MoneyRequestHeader.js index a0e0fc3b70ad..3ecc854951e0 100644 --- a/src/components/MoneyRequestHeader.js +++ b/src/components/MoneyRequestHeader.js @@ -90,6 +90,11 @@ function MoneyRequestHeader(props) { const shouldShowSettlementButton = !isSettled && !props.isSingleTransactionView && isPayer; const bankAccountRoute = ReportUtils.getBankAccountRoute(props.chatReport); const shouldShowPaypal = Boolean(lodashGet(props.personalDetails, [moneyRequestReport.managerID, 'payPalMeAddress'])); + const report = props.report; + if (props.isSingleTransactionView) { + report.ownerAccountID = lodashGet(props, ['parentReport', 'ownerAccountID'], null); + report.ownerEmail = lodashGet(props, ['parentReport', 'ownerEmail'], ''); + } return ( 0 || Boolean(props.prefixCharacter); - - this.state = { - isFocused: false, - labelTranslateY: new Animated.Value(activeLabel ? styleConst.ACTIVE_LABEL_TRANSLATE_Y : styleConst.INACTIVE_LABEL_TRANSLATE_Y), - labelScale: new Animated.Value(activeLabel ? styleConst.ACTIVE_LABEL_SCALE : styleConst.INACTIVE_LABEL_SCALE), - passwordHidden: props.secureTextEntry, - textInputWidth: 0, - prefixWidth: 0, - selection: props.selection, - height: variables.componentSizeLarge, - - // Value should be kept in state for the autoGrow feature to work - https://github.com/Expensify/App/pull/8232#issuecomment-1077282006 - value, - }; - - this.input = null; - this.isLabelActive = activeLabel; - this.onPress = this.onPress.bind(this); - this.onFocus = this.onFocus.bind(this); - this.onBlur = this.onBlur.bind(this); - this.setValue = this.setValue.bind(this); - this.togglePasswordVisibility = this.togglePasswordVisibility.bind(this); - this.dismissKeyboardWhenBackgrounded = this.dismissKeyboardWhenBackgrounded.bind(this); - this.storePrefixLayoutDimensions = this.storePrefixLayoutDimensions.bind(this); - } - - componentDidMount() { - if (this.props.disableKeyboard) { - this.appStateSubscription = AppState.addEventListener('change', this.dismissKeyboardWhenBackgrounded); +function BaseTextInput(props) { + const inputValue = props.value || props.defaultValue || ''; + const initialActiveLabel = props.forceActiveLabel || inputValue.length > 0 || Boolean(props.prefixCharacter); + + const [isFocused, setIsFocused] = useState(false); + const [passwordHidden, setPasswordHidden] = useState(props.secureTextEntry); + const [textInputWidth, setTextInputWidth] = useState(0); + const [textInputHeight, setTextInputHeight] = useState(0); + const [prefixWidth, setPrefixWidth] = useState(0); + const [height, setHeight] = useState(variables.componentSizeLarge); + const [width, setWidth] = useState(); + const labelScale = useRef(new Animated.Value(initialActiveLabel ? styleConst.ACTIVE_LABEL_SCALE : styleConst.INACTIVE_LABEL_SCALE)).current; + const labelTranslateY = useRef(new Animated.Value(initialActiveLabel ? styleConst.ACTIVE_LABEL_TRANSLATE_Y : styleConst.INACTIVE_LABEL_TRANSLATE_Y)).current; + + const input = useRef(null); + const isLabelActive = useRef(initialActiveLabel); + + useEffect(() => { + if (!props.disableKeyboard) { + return; } + const appStateSubscription = AppState.addEventListener('change', (nextAppState) => { + if (!nextAppState.match(/inactive|background/)) { + return; + } + + Keyboard.dismiss(); + }); + + return () => { + appStateSubscription.remove(); + }; + }, [props.disableKeyboard]); + + // AutoFocus which only works on mount: + useEffect(() => { // We are manually managing focus to prevent this issue: https://github.com/Expensify/App/issues/4514 - if (!this.props.autoFocus || !this.input) { + if (!props.autoFocus || !input.current) { return; } - if (this.props.shouldDelayFocus) { - this.focusTimeout = setTimeout(() => this.input.focus(), CONST.ANIMATED_TRANSITION); + let focusTimeout; + if (props.shouldDelayFocus) { + focusTimeout = setTimeout(() => input.current.focus(), CONST.ANIMATED_TRANSITION); return; } - this.input.focus(); - } + input.current.focus(); - componentDidUpdate(prevProps) { - // Activate or deactivate the label when value is changed programmatically from outside - const inputValue = _.isUndefined(this.props.value) ? this.input.value : this.props.value; - if ((_.isUndefined(inputValue) || this.state.value === inputValue) && _.isEqual(prevProps.selection, this.props.selection)) { + return () => { + if (!focusTimeout) { + return; + } + clearTimeout(focusTimeout); + }; + // We only want this to run on mount + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const animateLabel = useCallback( + (translateY, scale) => { + Animated.parallel([ + Animated.spring(labelTranslateY, { + toValue: translateY, + duration: styleConst.LABEL_ANIMATION_DURATION, + useNativeDriver: true, + }), + Animated.spring(labelScale, { + toValue: scale, + duration: styleConst.LABEL_ANIMATION_DURATION, + useNativeDriver: true, + }), + ]).start(); + }, + [labelScale, labelTranslateY], + ); + + const activateLabel = useCallback(() => { + const value = props.value || ''; + + if (value.length < 0 || isLabelActive.current) { return; } - // eslint-disable-next-line react/no-did-update-set-state - this.setState({value: inputValue, selection: this.props.selection}, () => { - if (this.state.value) { - this.activateLabel(); - } else if (!this.state.isFocused) { - this.deactivateLabel(); - } - }); + animateLabel(styleConst.ACTIVE_LABEL_TRANSLATE_Y, styleConst.ACTIVE_LABEL_SCALE); + isLabelActive.current = true; + }, [animateLabel, props.value]); - // In some cases, When the value prop is empty, it is not properly updated on the TextInput due to its uncontrolled nature, thus manually clearing the TextInput. - if (inputValue === '') { - this.input.clear(); + const deactivateLabel = useCallback(() => { + const value = props.value || ''; + + if (props.forceActiveLabel || value.length !== 0 || props.prefixCharacter) { + return; } - } - componentWillUnmount() { - if (this.focusTimeout) { - clearTimeout(this.focusTimeout); + animateLabel(styleConst.INACTIVE_LABEL_TRANSLATE_Y, styleConst.INACTIVE_LABEL_SCALE); + isLabelActive.current = false; + }, [animateLabel, props.forceActiveLabel, props.prefixCharacter, props.value]); + + const onFocus = (event) => { + if (props.onFocus) { + props.onFocus(event); } + setIsFocused(true); + }; - if (!this.props.disableKeyboard || !this.appStateSubscription) { - return; + const onBlur = (event) => { + if (props.onBlur) { + props.onBlur(event); } + setIsFocused(false); - this.appStateSubscription.remove(); - } + // If the text has been supplied by Chrome autofill, the value state is not synced with the value + // as Chrome doesn't trigger a change event. When there is autofill text, don't deactivate label. + if (!isInputAutoFilled(input.current)) { + deactivateLabel(); + } + }; - onPress(event) { - if (this.props.disabled) { + const onPress = (event) => { + if (props.disabled) { return; } - if (this.props.onPress) { - this.props.onPress(event); + if (props.onPress) { + props.onPress(event); } if (!event.isDefaultPrevented()) { - this.input.focus(); + input.current.focus(); } - } + }; - onFocus(event) { - if (this.props.onFocus) { - this.props.onFocus(event); - } - this.setState({isFocused: true}); - this.activateLabel(); - } + const onLayout = useCallback( + (event) => { + if (!props.autoGrowHeight && props.multiline) { + return; + } + + const layout = event.nativeEvent.layout; + + setWidth((prevWidth) => (props.autoGrowHeight ? layout.width : prevWidth)); + setHeight((prevHeight) => (!props.multiline ? layout.height : prevHeight)); + }, + [props.autoGrowHeight, props.multiline], + ); + + useEffect(() => { + // Handle side effects when the value gets changed programatically from the outside - onBlur(event) { - if (this.props.onBlur) { - this.props.onBlur(event); + // In some cases, When the value prop is empty, it is not properly updated on the TextInput due to its uncontrolled nature, thus manually clearing the TextInput. + if (inputValue === '') { + input.current.clear(); } - this.setState({isFocused: false}); - // If the text has been supplied by Chrome autofill, the value state is not synced with the value - // as Chrome doesn't trigger a change event. When there is autofill text, don't deactivate label. - if (!isInputAutoFilled(this.input)) { - this.deactivateLabel(); + if (inputValue) { + activateLabel(); } - } + }, [activateLabel, inputValue]); + + // We capture whether the input has a value or not in a ref. + // It gets updated when the text gets changed. + const hasValueRef = useRef(inputValue.length > 0); + + // Activate or deactivate the label when the focus changes: + useEffect(() => { + // We can't use inputValue here directly, as it might contain + // the defaultValue, which doesn't get updated when the text changes. + // We can't use props.value either, as it might be undefined. + if (hasValueRef.current || isFocused) { + activateLabel(); + } else if (!hasValueRef.current && !isFocused) { + deactivateLabel(); + } + }, [activateLabel, deactivateLabel, inputValue, isFocused]); /** * Set Value & activateLabel @@ -146,258 +203,202 @@ class BaseTextInput extends Component { * @param {String} value * @memberof BaseTextInput */ - setValue(value) { - if (this.props.onInputChange) { - this.props.onInputChange(value); - } - this.setState({value}); - Str.result(this.props.onChangeText, value); - this.activateLabel(); - } - - activateLabel() { - if (this.state.value.length < 0 || this.isLabelActive) { - return; + const setValue = (value) => { + if (props.onInputChange) { + props.onInputChange(value); } - this.animateLabel(styleConst.ACTIVE_LABEL_TRANSLATE_Y, styleConst.ACTIVE_LABEL_SCALE); - this.isLabelActive = true; - } - - deactivateLabel() { - if (this.props.forceActiveLabel || this.state.value.length !== 0 || this.props.prefixCharacter) { - return; - } - - this.animateLabel(styleConst.INACTIVE_LABEL_TRANSLATE_Y, styleConst.INACTIVE_LABEL_SCALE); - this.isLabelActive = false; - } - - dismissKeyboardWhenBackgrounded(nextAppState) { - if (!nextAppState.match(/inactive|background/)) { - return; + Str.result(props.onChangeText, value); + if (value && value.length > 0) { + hasValueRef.current = true; + activateLabel(); + } else { + hasValueRef.current = false; } - - Keyboard.dismiss(); - } - - animateLabel(translateY, scale) { - Animated.parallel([ - Animated.spring(this.state.labelTranslateY, { - toValue: translateY, - duration: styleConst.LABEL_ANIMATION_DURATION, - useNativeDriver: true, - }), - Animated.spring(this.state.labelScale, { - toValue: scale, - duration: styleConst.LABEL_ANIMATION_DURATION, - useNativeDriver: true, - }), - ]).start(); - } - - togglePasswordVisibility() { - this.setState((prevState) => ({passwordHidden: !prevState.passwordHidden})); - } - - storePrefixLayoutDimensions(event) { - this.setState({prefixWidth: Math.abs(event.nativeEvent.layout.width)}); - } - - render() { - // eslint-disable-next-line react/forbid-foreign-prop-types - const inputProps = _.omit(this.props, _.keys(baseTextInputPropTypes.propTypes)); - const hasLabel = Boolean(this.props.label.length); - const isEditable = _.isUndefined(this.props.editable) ? !this.props.disabled : this.props.editable; - const inputHelpText = this.props.errorText || this.props.hint; - const placeholder = this.props.prefixCharacter || this.state.isFocused || !hasLabel || (hasLabel && this.props.forceActiveLabel) ? this.props.placeholder : null; - const maxHeight = StyleSheet.flatten(this.props.containerStyles).maxHeight; - const textInputContainerStyles = _.reduce( - [ - styles.textInputContainer, - ...this.props.textInputContainerStyles, - this.props.autoGrow && StyleUtils.getWidthStyle(this.state.textInputWidth), - !this.props.hideFocusedState && this.state.isFocused && styles.borderColorFocus, - (this.props.hasError || this.props.errorText) && styles.borderColorDanger, - this.props.autoGrowHeight && {scrollPaddingTop: 2 * maxHeight}, - ], - (finalStyles, s) => ({...finalStyles, ...s}), - {}, - ); - const isMultiline = this.props.multiline || this.props.autoGrowHeight; - - return ( - <> - - { + setPasswordHidden((prevState) => !prevState.passwordHidden); + }, []); + + const storePrefixLayoutDimensions = useCallback((event) => { + setPrefixWidth(Math.abs(event.nativeEvent.layout.width)); + }, []); + + // eslint-disable-next-line react/forbid-foreign-prop-types + const inputProps = _.omit(props, _.keys(baseTextInputPropTypes.propTypes)); + const hasLabel = Boolean(props.label.length); + const isEditable = _.isUndefined(props.editable) ? !props.disabled : props.editable; + const inputHelpText = props.errorText || props.hint; + const placeholder = props.prefixCharacter || isFocused || !hasLabel || (hasLabel && props.forceActiveLabel) ? props.placeholder : null; + const maxHeight = StyleSheet.flatten(props.containerStyles).maxHeight; + const textInputContainerStyles = StyleSheet.flatten([ + styles.textInputContainer, + ...props.textInputContainerStyles, + props.autoGrow && StyleUtils.getWidthStyle(textInputWidth), + !props.hideFocusedState && isFocused && styles.borderColorFocus, + (props.hasError || props.errorText) && styles.borderColorDanger, + props.autoGrowHeight && {scrollPaddingTop: 2 * maxHeight}, + ]); + const isMultiline = props.multiline || props.autoGrowHeight; + + return ( + <> + + + - { - if (!this.props.autoGrowHeight && this.props.multiline) { - return; - } - - const layout = event.nativeEvent.layout; - - this.setState((prevState) => ({ - width: this.props.autoGrowHeight ? layout.width : prevState.width, - height: !isMultiline ? layout.height : prevState.height, - })); - }} - style={[ - textInputContainerStyles, - - // When autoGrow is on and minWidth is not supplied, add a minWidth to allow the input to be focusable. - this.props.autoGrow && !textInputContainerStyles.minWidth && styles.mnw2, - ]} - > - {hasLabel ? ( - <> - {/* Adding this background to the label only for multiline text input, - to prevent text overlapping with label when scrolling */} - {isMultiline && ( - - )} - + {/* Adding this background to the label only for multiline text input, + to prevent text overlapping with label when scrolling */} + {isMultiline && ( + - - ) : null} - - {Boolean(this.props.prefixCharacter) && ( - - - {this.props.prefixCharacter} - - )} - { - if (typeof this.props.innerRef === 'function') { - this.props.innerRef(ref); - } else if (this.props.innerRef && _.has(this.props.innerRef, 'current')) { - this.props.innerRef.current = ref; - } - this.input = ref; - }} - // eslint-disable-next-line - {...inputProps} - autoCorrect={this.props.secureTextEntry ? false : this.props.autoCorrect} - placeholder={placeholder} - placeholderTextColor={themeColors.placeholderText} - underlineColorAndroid="transparent" - style={[ - styles.flex1, - styles.w100, - this.props.inputStyle, - (!hasLabel || isMultiline) && styles.pv0, - this.props.prefixCharacter && StyleUtils.getPaddingLeft(this.state.prefixWidth + styles.pl1.paddingLeft), - this.props.secureTextEntry && styles.secureInput, - - // Explicitly remove `lineHeight` from single line inputs so that long text doesn't disappear - // once it exceeds the input space (See https://github.com/Expensify/App/issues/13802) - !isMultiline && {height: this.state.height, lineHeight: undefined}, - - // Stop scrollbar flashing when breaking lines with autoGrowHeight enabled. - this.props.autoGrowHeight && StyleUtils.getAutoGrowHeightInputStyle(this.state.textInputHeight, maxHeight), - ]} - multiline={isMultiline} - maxLength={this.props.maxLength} - onFocus={this.onFocus} - onBlur={this.onBlur} - onChangeText={this.setValue} - secureTextEntry={this.state.passwordHidden} - onPressOut={this.props.onPress} - showSoftInputOnFocus={!this.props.disableKeyboard} - keyboardType={getSecureEntryKeyboardType(this.props.keyboardType, this.props.secureTextEntry, this.state.passwordHidden)} - value={this.state.value} - selection={this.state.selection} - editable={isEditable} - // FormSubmit Enter key handler does not have access to direct props. - // `dataset.submitOnEnter` is used to indicate that pressing Enter on this input should call the submit callback. - dataSet={{submitOnEnter: isMultiline && this.props.submitOnEnter}} + - {Boolean(this.props.secureTextEntry) && ( - e.preventDefault()} - accessibilityLabel={this.props.translate('common.visible')} + + ) : null} + + {Boolean(props.prefixCharacter) && ( + + - - - )} - {!this.props.secureTextEntry && Boolean(this.props.icon) && ( - - - - )} - + {props.prefixCharacter} + + + )} + { + if (typeof props.innerRef === 'function') { + props.innerRef(ref); + } else if (props.innerRef && _.has(props.innerRef, 'current')) { + // eslint-disable-next-line no-param-reassign + props.innerRef.current = ref; + } + input.current = ref; + }} + // eslint-disable-next-line + {...inputProps} + autoCorrect={props.secureTextEntry ? false : props.autoCorrect} + placeholder={placeholder} + placeholderTextColor={themeColors.placeholderText} + underlineColorAndroid="transparent" + style={[ + styles.flex1, + styles.w100, + props.inputStyle, + (!hasLabel || isMultiline) && styles.pv0, + props.prefixCharacter && StyleUtils.getPaddingLeft(prefixWidth + styles.pl1.paddingLeft), + props.secureTextEntry && styles.secureInput, + + // Explicitly remove `lineHeight` from single line inputs so that long text doesn't disappear + // once it exceeds the input space (See https://github.com/Expensify/App/issues/13802) + !isMultiline && {height, lineHeight: undefined}, + + // Stop scrollbar flashing when breaking lines with autoGrowHeight enabled. + props.autoGrowHeight && StyleUtils.getAutoGrowHeightInputStyle(textInputHeight, maxHeight), + ]} + multiline={isMultiline} + maxLength={props.maxLength} + onFocus={onFocus} + onBlur={onBlur} + onChangeText={setValue} + secureTextEntry={passwordHidden} + onPressOut={props.onPress} + showSoftInputOnFocus={!props.disableKeyboard} + keyboardType={getSecureEntryKeyboardType(props.keyboardType, props.secureTextEntry, passwordHidden)} + value={props.value} + selection={props.selection} + editable={isEditable} + defaultValue={props.defaultValue} + // FormSubmit Enter key handler does not have access to direct props. + // `dataset.submitOnEnter` is used to indicate that pressing Enter on this input should call the submit callback. + dataSet={{submitOnEnter: isMultiline && props.submitOnEnter}} + /> + {Boolean(props.secureTextEntry) && ( + e.preventDefault()} + accessibilityLabel={props.translate('common.visible')} + > + + + )} + {!props.secureTextEntry && Boolean(props.icon) && ( + + + + )} - - {!_.isEmpty(inputHelpText) && ( - - )} - - {/* - Text input component doesn't support auto grow by default. - We're using a hidden text input to achieve that. - This text view is used to calculate width or height of the input value given textStyle in this component. - This Text component is intentionally positioned out of the screen. - */} - {(this.props.autoGrow || this.props.autoGrowHeight) && ( - // Add +2 to width so that the first digit of amount do not cut off on mWeb - https://github.com/Expensify/App/issues/8158. - this.setState({textInputWidth: e.nativeEvent.layout.width + 2, textInputHeight: e.nativeEvent.layout.height})} - > - {this.state.value || this.props.placeholder} - + + + {!_.isEmpty(inputHelpText) && ( + )} - - ); - } + + {/* + Text input component doesn't support auto grow by default. + We're using a hidden text input to achieve that. + This text view is used to calculate width or height of the input value given textStyle in this component. + This Text component is intentionally positioned out of the screen. + */} + {(props.autoGrow || props.autoGrowHeight) && ( + // Add +2 to width so that the first digit of amount do not cut off on mWeb - https://github.com/Expensify/App/issues/8158. + { + setTextInputWidth(e.nativeEvent.layout.width + 2); + setTextInputHeight(e.nativeEvent.layout.height); + }} + > + {props.value || props.placeholder} + + )} + + ); } +BaseTextInput.displayName = 'BaseTextInput'; BaseTextInput.propTypes = baseTextInputPropTypes.propTypes; BaseTextInput.defaultProps = baseTextInputPropTypes.defaultProps; diff --git a/src/components/TextInput/baseTextInputPropTypes.js b/src/components/TextInput/baseTextInputPropTypes.js index 2e278bab5d69..8a1b05a628c2 100644 --- a/src/components/TextInput/baseTextInputPropTypes.js +++ b/src/components/TextInput/baseTextInputPropTypes.js @@ -40,10 +40,18 @@ const propTypes = { /** Disable the virtual keyboard */ disableKeyboard: PropTypes.bool, - /** Autogrow input container length based on the entered text */ + /** + * Autogrow input container length based on the entered text. + * Note: If you use this prop, the text input has to be controlled + * by a value prop. + */ autoGrow: PropTypes.bool, - /** Autogrow input container height based on the entered text */ + /** + * Autogrow input container height based on the entered text + * Note: If you use this prop, the text input has to be controlled + * by a value prop. + */ autoGrowHeight: PropTypes.bool, /** Hide the focus styles on TextInput */ diff --git a/src/components/UserDetailsTooltip/index.js b/src/components/UserDetailsTooltip/index.js index 16fdacd8964a..18069bc8fde8 100644 --- a/src/components/UserDetailsTooltip/index.js +++ b/src/components/UserDetailsTooltip/index.js @@ -12,6 +12,7 @@ import ONYXKEYS from '../../ONYXKEYS'; import withLocalize from '../withLocalize'; import compose from '../../libs/compose'; import * as UserUtils from '../../libs/UserUtils'; +import * as LocalePhoneNumber from '../../libs/LocalePhoneNumber'; function UserDetailsTooltip(props) { const userDetails = lodashGet(props.personalDetailsList, props.accountID, props.fallbackUserDetails); @@ -44,7 +45,7 @@ function UserDetailsTooltip(props) { {userDisplayName} - {(userLogin || '').trim() && !_.isEqual(userLogin, userDisplayName) ? Str.removeSMSDomain(userLogin) : ''} + {(userLogin || '').trim() && !_.isEqual(LocalePhoneNumber.formatPhoneNumber(userLogin || ''), userDisplayName) ? Str.removeSMSDomain(userLogin) : ''} ), diff --git a/src/hooks/usePermissions.js b/src/hooks/usePermissions.js new file mode 100644 index 000000000000..1c31ffc8bb64 --- /dev/null +++ b/src/hooks/usePermissions.js @@ -0,0 +1,15 @@ +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(() => { + const permissions = {}; + _.each(Permissions, (checkerFunction, beta) => { + permissions[beta] = checkerFunction(betas); + }); + return permissions; + }, [betas]); +} diff --git a/src/languages/en.js b/src/languages/en.js index ceaa2d488d55..f6989d32f35a 100755 --- a/src/languages/en.js +++ b/src/languages/en.js @@ -560,7 +560,6 @@ export default { stepSuccess: 'Finished', enabled: 'Two-factor authentication is now enabled!', congrats: 'Congrats, now you’ve got that extra security.', - copyCodes: 'Copy codes', copy: 'Copy', disable: 'Disable', }, @@ -595,7 +594,7 @@ export default { growlMessageOnSave: 'Your debit card was successfully added', expensifyPassword: 'Expensify password', error: { - invalidName: 'Please enter a valid name', + invalidName: 'Name can only include latin letters and numbers.', addressZipCode: 'Please enter a valid zip code', debitCardNumber: 'Please enter a valid debit card number', expirationDate: 'Please select a valid expiration date', @@ -707,6 +706,7 @@ export default { enterAuthenticatorCode: 'Please enter your authenticator code', requiredWhen2FAEnabled: 'Required when 2FA is enabled', requestNewCode: 'Request a new code in ', + requestNewCodeAfterErrorOccurred: 'Request a new code', error: { pleaseFillMagicCode: 'Please enter your magic code', incorrectMagicCode: 'Incorrect magic code.', @@ -1424,7 +1424,7 @@ export default { lastReply: 'Last reply', replies: 'Replies', reply: 'Reply', - from: 'From', + parentNavigationSummary: ({rootReportName, workspaceName}) => `From ${rootReportName}${workspaceName ? ` in ${workspaceName}` : ''}`, }, qrCodes: { copyUrlToClipboard: 'Copy URL to clipboard', diff --git a/src/languages/es.js b/src/languages/es.js index 0c605be9c174..f765ce0270df 100644 --- a/src/languages/es.js +++ b/src/languages/es.js @@ -561,7 +561,6 @@ export default { stepSuccess: 'Finalizado', enabled: '¡La autenticación de dos factores ahora está habilitada!', congrats: 'Felicidades, ahora tienes esa seguridad adicional.', - copyCodes: 'Copiar códigos', copy: 'Copiar', disable: 'Deshabilitar', }, @@ -596,7 +595,7 @@ export default { growlMessageOnSave: 'Su tarteja de débito se agregó correctamente', expensifyPassword: 'Contraseña de Expensify', error: { - invalidName: 'Por favor, introduce un nombre válido', + invalidName: 'El nombre solo puede contener números y caracteres latinos.', addressZipCode: 'Por favor, introduce un código postal válido', debitCardNumber: 'Por favor, introduce un número de tarjeta de débito válido', expirationDate: 'Por favor, selecciona una fecha de vencimiento válida', @@ -709,6 +708,7 @@ export default { enterAuthenticatorCode: 'Por favor, introduce el código de autenticador', requiredWhen2FAEnabled: 'Obligatorio cuando A2F está habilitado', requestNewCode: 'Pedir un código nuevo en ', + requestNewCodeAfterErrorOccurred: 'Solicitar un nuevo código', error: { pleaseFillMagicCode: 'Por favor, introduce el código mágico', incorrectMagicCode: 'Código mágico incorrecto.', @@ -1893,7 +1893,7 @@ export default { lastReply: 'Última respuesta', replies: 'Respuestas', reply: 'Respuesta', - from: 'De', + parentNavigationSummary: ({rootReportName, workspaceName}) => `De ${rootReportName}${workspaceName ? ` en ${workspaceName}` : ''}`, }, qrCodes: { copyUrlToClipboard: 'Copiar URL al portapapeles', diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.js b/src/libs/Navigation/AppNavigator/AuthScreens.js index fb02a811b0ec..e943d529d86b 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.js +++ b/src/libs/Navigation/AppNavigator/AuthScreens.js @@ -261,6 +261,18 @@ class AuthScreens extends React.Component { return ConciergePage; }} /> + { + const ReportAttachments = require('../../../pages/home/report/ReportAttachments').default; + return ReportAttachments; + }} + listeners={modalScreenListeners} + /> { @@ -127,19 +128,24 @@ function dismissModal(targetReportID) { } const rootState = navigationRef.getRootState(); const lastRoute = _.last(rootState.routes); - if (lastRoute.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR || lastRoute.name === NAVIGATORS.FULL_SCREEN_NAVIGATOR) { - // if we are not in the target report, we need to navigate to it after dismissing the modal - if (targetReportID && targetReportID !== getTopmostReportId(rootState)) { - const state = getStateFromPath(ROUTES.getReportRoute(targetReportID)); - - const action = getActionFromState(state, linkingConfig.config); - action.type = 'REPLACE'; - navigationRef.current.dispatch(action); - } else { - navigationRef.current.dispatch({...StackActions.pop(), target: rootState.key}); + switch (lastRoute.name) { + case NAVIGATORS.RIGHT_MODAL_NAVIGATOR: + case NAVIGATORS.FULL_SCREEN_NAVIGATOR: + case SCREENS.REPORT_ATTACHMENTS: + // if we are not in the target report, we need to navigate to it after dismissing the modal + if (targetReportID && targetReportID !== getTopmostReportId(rootState)) { + const state = getStateFromPath(ROUTES.getReportRoute(targetReportID)); + + const action = getActionFromState(state, linkingConfig.config); + action.type = 'REPLACE'; + navigationRef.current.dispatch(action); + } else { + navigationRef.current.dispatch({...StackActions.pop(), target: rootState.key}); + } + break; + default: { + Log.hmmm('[Navigation] dismissModal failed because there is no modal stack to dismiss'); } - } else { - Log.hmmm('[Navigation] dismissModal failed because there is no modal stack to dismiss'); } } diff --git a/src/libs/Navigation/linkingConfig.js b/src/libs/Navigation/linkingConfig.js index 4854d7ea96f4..204e81dfec29 100644 --- a/src/libs/Navigation/linkingConfig.js +++ b/src/libs/Navigation/linkingConfig.js @@ -13,6 +13,7 @@ export default { UnlinkLogin: ROUTES.UNLINK_LOGIN, [SCREENS.TRANSITION_FROM_OLD_DOT]: ROUTES.TRANSITION_FROM_OLD_DOT, Concierge: ROUTES.CONCIERGE, + [SCREENS.REPORT_ATTACHMENTS]: ROUTES.REPORT_ATTACHMENTS, // Sidebar [SCREENS.HOME]: { diff --git a/src/libs/OptionsListUtils.js b/src/libs/OptionsListUtils.js index 72f8f297344e..aa3f600cb9d7 100644 --- a/src/libs/OptionsListUtils.js +++ b/src/libs/OptionsListUtils.js @@ -461,7 +461,7 @@ function createOption(accountIDs, personalDetails, report, reportActions = {}, { result.isArchivedRoom = ReportUtils.isArchivedRoom(report); result.isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(report); result.isMoneyRequestReport = ReportUtils.isMoneyRequestReport(report); - result.isThread = ReportUtils.isThread(report); + result.isThread = ReportUtils.isChatThread(report); result.isTaskReport = ReportUtils.isTaskReport(report); result.shouldShowSubscript = ReportUtils.shouldReportShowSubscript(report); result.allReportErrors = getAllReportErrors(report, reportActions); @@ -644,7 +644,7 @@ function getOptions( return; } - const isThread = ReportUtils.isThread(report); + const isThread = ReportUtils.isChatThread(report); const isChatRoom = ReportUtils.isChatRoom(report); const isTaskReport = ReportUtils.isTaskReport(report); const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(report); diff --git a/src/libs/Permissions.js b/src/libs/Permissions.js index d3e407260e20..cce0fc984dab 100644 --- a/src/libs/Permissions.js +++ b/src/libs/Permissions.js @@ -1,5 +1,4 @@ import _ from 'underscore'; -import * as Environment from './Environment/Environment'; import CONST from '../CONST'; /** @@ -8,7 +7,7 @@ import CONST from '../CONST'; * @returns {Boolean} */ function canUseAllBetas(betas) { - return Environment.isDevelopment() || _.contains(betas, CONST.BETAS.ALL); + return _.contains(betas, CONST.BETAS.ALL); } /** @@ -75,7 +74,7 @@ function canUseCommentLinking(betas) { * @returns {Boolean} */ function canUsePolicyRooms(betas) { - return _.contains(betas, CONST.BETAS.POLICY_ROOMS) || _.contains(betas, CONST.BETAS.ALL); + return _.contains(betas, CONST.BETAS.POLICY_ROOMS) || canUseAllBetas(betas); } /** @@ -91,7 +90,7 @@ function canUsePolicyExpenseChat(betas) { * @returns {Boolean} */ function canUsePasswordlessLogins(betas) { - return _.contains(betas, CONST.BETAS.PASSWORDLESS) || _.contains(betas, CONST.BETAS.ALL); + return _.contains(betas, CONST.BETAS.PASSWORDLESS) || canUseAllBetas(betas); } /** @@ -99,7 +98,7 @@ function canUsePasswordlessLogins(betas) { * @returns {Boolean} */ function canUseTasks(betas) { - return _.contains(betas, CONST.BETAS.TASKS) || _.contains(betas, CONST.BETAS.ALL); + return _.contains(betas, CONST.BETAS.TASKS) || canUseAllBetas(betas); } export default { diff --git a/src/libs/ReportActionsUtils.js b/src/libs/ReportActionsUtils.js index e3ddff17c69a..bead882e2e15 100644 --- a/src/libs/ReportActionsUtils.js +++ b/src/libs/ReportActionsUtils.js @@ -248,6 +248,11 @@ function isConsecutiveActionMadeByPreviousActor(reportActions, actionIndex) { return false; } + // Do not group if the delegate account ID is different + if (previousAction.delegateAccountID !== currentAction.delegateAccountID) { + return false; + } + return currentAction.actorEmail === previousAction.actorEmail; } @@ -425,6 +430,16 @@ function getLinkedTransactionID(reportID, reportActionID) { return reportAction.originalMessage.IOUTransactionID; } +/** + * + * @param {String} reportID + * @param {String} reportActionID + * @returns {Object} + */ +function getReportAction(reportID, reportActionID) { + return lodashGet(allReportActions, [reportID, reportActionID], {}); +} + /** * @param {*} chatReportID * @param {*} iouReportID @@ -492,4 +507,5 @@ export { isMessageDeleted, isWhisperAction, isPendingRemove, + getReportAction, }; diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js index 4ddbf032867c..5de8abb15ed6 100644 --- a/src/libs/ReportUtils.js +++ b/src/libs/ReportUtils.js @@ -378,13 +378,23 @@ function getBankAccountRoute(report) { } /** - * Returns true if report has a parent and is therefore a Thread. + * Returns true if report has a parent * * @param {Object} report * @returns {Boolean} */ function isThread(report) { - return Boolean(report && report.parentReportID && report.parentReportActionID && report.type === CONST.REPORT.TYPE.CHAT); + return Boolean(report && report.parentReportID && report.parentReportActionID); +} + +/** + * Returns true if report is of type chat and has a parent and is therefore a Thread. + * + * @param {Object} report + * @returns {Boolean} + */ +function isChatThread(report) { + return isThread(report) && report.type === CONST.REPORT.TYPE.CHAT; } /** @@ -394,7 +404,7 @@ function isThread(report) { * @returns {Boolean} */ function isConciergeChatReport(report) { - return lodashGet(report, 'participantAccountIDs', []).length === 1 && Number(report.participantAccountIDs[0]) === CONST.ACCOUNT_ID.CONCIERGE && !isThread(report); + return lodashGet(report, 'participantAccountIDs', []).length === 1 && Number(report.participantAccountIDs[0]) === CONST.ACCOUNT_ID.CONCIERGE && !isChatThread(report); } /** @@ -480,9 +490,15 @@ function isArchivedRoom(report) { * @param {String} report.policyID * @param {String} report.oldPolicyName * @param {String} report.policyName + * @param {Boolean} [returnEmptyIfNotFound] * @returns {String} */ -function getPolicyName(report) { +function getPolicyName(report, returnEmptyIfNotFound = false) { + const noPolicyFound = returnEmptyIfNotFound ? '' : Localize.translateLocal('workspace.common.unavailable'); + if (report === undefined) { + return noPolicyFound; + } + if ((!allPolicies || _.size(allPolicies) === 0) && !report.policyName) { return Localize.translateLocal('workspace.common.unavailable'); } @@ -491,7 +507,7 @@ function getPolicyName(report) { // // Public rooms send back the policy name with the reportSummary, // // since they can also be accessed by people who aren't in the workspace - return lodashGet(policy, 'name') || report.policyName || report.oldPolicyName || Localize.translateLocal('workspace.common.unavailable'); + return lodashGet(policy, 'name') || report.policyName || report.oldPolicyName || noPolicyFound; } /** @@ -734,7 +750,7 @@ function getIcons(report, personalDetails, defaultIcon = null, isPayer = false, result.source = Expensicons.DeletedRoomAvatar; return [result]; } - if (isThread(report)) { + if (isChatThread(report)) { const parentReportAction = ReportActionsUtils.getParentReportAction(report); const actorEmail = lodashGet(parentReportAction, 'actorEmail', ''); @@ -1030,7 +1046,7 @@ function getReportPreviewMessage(report, reportAction) { */ function getReportName(report) { let formattedName; - if (isThread(report)) { + if (isChatThread(report)) { const parentReportAction = ReportActionsUtils.getParentReportAction(report); if (ReportActionsUtils.isTransactionThread(parentReportAction)) { return getTransactionReportName(parentReportAction); @@ -1072,17 +1088,33 @@ function getReportName(report) { } /** - * Recursively navigates through parent to get the root reports name only for DM reports. + * Recursively navigates through thread parents to get the root report and workspace name. + * The recursion stops when we find a non thread or money request report, whichever comes first. * @param {Object} report - * @returns {String|*} + * @returns {Object} */ -function getDMRootReportName(report) { - if (isThread(report) && !getChatType(report)) { +function getRootReportAndWorkspaceName(report) { + if (isThread(report) && !isMoneyRequestReport(report)) { const parentReport = lodashGet(allReports, [`${ONYXKEYS.COLLECTION.REPORT}${report.parentReportID}`]); - return getDMRootReportName(parentReport); + return getRootReportAndWorkspaceName(parentReport); + } + + if (isIOUReport(report)) { + return { + rootReportName: lodashGet(report, 'displayName', ''), + }; + } + if (isMoneyRequestReport(report)) { + return { + rootReportName: lodashGet(report, 'displayName', ''), + workspaceName: isIOUReport(report) ? CONST.POLICY.OWNER_EMAIL_FAKE : getPolicyName(report, true), + }; } - return getReportName(report); + return { + rootReportName: getReportName(report), + workspaceName: getPolicyName(report, true), + }; } /** @@ -1091,23 +1123,8 @@ function getDMRootReportName(report) { * @returns {String} */ function getChatRoomSubtitle(report) { - if (isThread(report)) { - if (!getChatType(report)) { - return `${Localize.translateLocal('threads.from')} ${getDMRootReportName(report)}`; - } - - let roomName = ''; - if (isChatRoom(report)) { - const parentReport = lodashGet(allReports, [`${ONYXKEYS.COLLECTION.REPORT}${report.parentReportID}`]); - if (parentReport) { - roomName = lodashGet(parentReport, 'displayName', ''); - } else { - roomName = lodashGet(report, 'displayName', ''); - } - } - - const workspaceName = getPolicyName(report); - return `${Localize.translateLocal('threads.from')} ${roomName ? [roomName, workspaceName].join(' in ') : workspaceName}`; + if (isChatThread(report)) { + return ''; } if (!isDefaultRoom(report) && !isUserCreatedPolicyRoom(report) && !isPolicyExpenseChat(report)) { return ''; @@ -1125,6 +1142,24 @@ function getChatRoomSubtitle(report) { return getPolicyName(report); } +/** + * Gets the parent navigation subtitle for the report + * @param {Object} report + * @returns {String} + */ +function getParentNavigationSubtitle(report) { + if (isThread(report)) { + const parentReport = lodashGet(allReports, [`${ONYXKEYS.COLLECTION.REPORT}${report.parentReportID}`]); + const {rootReportName, workspaceName} = getRootReportAndWorkspaceName(parentReport); + if (_.isEmpty(rootReportName)) { + return ''; + } + + return Localize.translateLocal('threads.parentNavigationSummary', {rootReportName, workspaceName}); + } + return ''; +} + /** * Get the report for a reportID * @@ -1143,7 +1178,7 @@ function getReport(reportID) { function navigateToDetailsPage(report) { const participantAccountIDs = lodashGet(report, 'participantAccountIDs', []); - if (isChatRoom(report) || isPolicyExpenseChat(report) || isThread(report)) { + if (isChatRoom(report) || isPolicyExpenseChat(report) || isChatThread(report)) { Navigation.navigate(ROUTES.getReportDetailsRoute(report.reportID)); return; } @@ -1961,7 +1996,7 @@ function shouldReportBeInOptionList(report, currentReportId, isInGSDMode, iouRep if ( !report || !report.reportID || - (_.isEmpty(report.participantAccountIDs) && !isThread(report) && !isPublicRoom(report) && !isArchivedRoom(report) && !isMoneyRequestReport(report) && !isTaskReport(report)) + (_.isEmpty(report.participantAccountIDs) && !isChatThread(report) && !isPublicRoom(report) && !isArchivedRoom(report) && !isMoneyRequestReport(report) && !isTaskReport(report)) ) { return false; } @@ -2021,7 +2056,7 @@ function getChatByParticipants(newParticipantList) { newParticipantList.sort(); return _.find(allReports, (report) => { // If the report has been deleted, or there are no participants (like an empty #admins room) then skip it - if (!report || !report.participantAccountIDs || isThread(report)) { + if (!report || !report.participantAccountIDs || isChatThread(report)) { return false; } @@ -2201,7 +2236,7 @@ function canRequestMoney(report) { */ function getMoneyRequestOptions(report, reportParticipants, betas) { // In any thread, we do not allow any new money requests yet - if (isThread(report)) { + if (isChatThread(report)) { return []; } @@ -2307,7 +2342,7 @@ function shouldReportShowSubscript(report) { return false; } - if (isPolicyExpenseChat(report) && !isThread(report) && !isTaskReport(report) && !report.isOwnPolicyExpenseChat) { + if (isPolicyExpenseChat(report) && !isChatThread(report) && !isTaskReport(report) && !report.isOwnPolicyExpenseChat) { return true; } @@ -2372,6 +2407,7 @@ export { isUserCreatedPolicyRoom, isChatRoom, getChatRoomSubtitle, + getParentNavigationSubtitle, getPolicyName, getPolicyType, isArchivedRoom, @@ -2440,6 +2476,7 @@ export { getWhisperDisplayNames, getWorkspaceAvatar, isThread, + isChatThread, isThreadParent, isThreadFirstChat, shouldReportShowSubscript, diff --git a/src/libs/SidebarUtils.js b/src/libs/SidebarUtils.js index ee53cd9df69a..f722f0cd1572 100644 --- a/src/libs/SidebarUtils.js +++ b/src/libs/SidebarUtils.js @@ -263,7 +263,7 @@ function getOptionData(reportID) { const participantPersonalDetailList = _.values(OptionsListUtils.getPersonalDetailsForAccountIDs(report.participantAccountIDs, personalDetails)); const personalDetail = participantPersonalDetailList[0] || {}; - result.isThread = ReportUtils.isThread(report); + result.isThread = ReportUtils.isChatThread(report); result.isChatRoom = ReportUtils.isChatRoom(report); result.isTaskReport = ReportUtils.isTaskReport(report); if (result.isTaskReport) { diff --git a/src/libs/ValidationUtils.js b/src/libs/ValidationUtils.js index 0fdc65bc7a82..6f05da08c703 100644 --- a/src/libs/ValidationUtils.js +++ b/src/libs/ValidationUtils.js @@ -140,19 +140,6 @@ function isValidDebitCard(string) { return validateCardNumber(string); } -/** - * - * @param {String} nameOnCard - * @returns {Boolean} - */ -function isValidCardName(nameOnCard) { - if (!CONST.REGEX.ALPHABETIC_CHARS.test(nameOnCard)) { - return false; - } - - return !_.isEmpty(nameOnCard.trim()); -} - /** * @param {String} code * @returns {Boolean} @@ -464,7 +451,6 @@ export { getAgeRequirementError, isValidAddress, isValidDate, - isValidCardName, isValidPastDate, isValidSecurityCode, isValidExpirationDate, diff --git a/src/libs/actions/App.js b/src/libs/actions/App.js index 38638e22a464..d4012829b90c 100644 --- a/src/libs/actions/App.js +++ b/src/libs/actions/App.js @@ -31,18 +31,6 @@ Onyx.connect({ initWithStoredValues: false, }); -let myPersonalDetails; -Onyx.connect({ - key: ONYXKEYS.PERSONAL_DETAILS_LIST, - callback: (val) => { - if (!val || !currentUserAccountID) { - return; - } - - myPersonalDetails = val[currentUserAccountID]; - }, -}); - let allPolicies = []; Onyx.connect({ key: ONYXKEYS.COLLECTION.POLICY, @@ -267,8 +255,8 @@ function setUpPoliciesAndNavigate(session) { } } -function openProfile() { - const oldTimezoneData = myPersonalDetails.timezone || {}; +function openProfile(personalDetails) { + const oldTimezoneData = personalDetails.timezone || {}; let newTimezoneData = oldTimezoneData; if (lodashGet(oldTimezoneData, 'automatic', true)) { @@ -308,8 +296,6 @@ function openProfile() { ], }, ); - - Navigation.navigate(ROUTES.SETTINGS_PROFILE); } export {setLocale, setLocaleAndNavigate, setSidebarLoaded, setUpPoliciesAndNavigate, openProfile, openApp, reconnectApp, confirmReadyToOpenApp}; diff --git a/src/libs/actions/Link.js b/src/libs/actions/Link.js index 09c040ab47bc..b920cb1c7ee6 100644 --- a/src/libs/actions/Link.js +++ b/src/libs/actions/Link.js @@ -66,18 +66,13 @@ function openOldDotLink(url) { } // If shortLivedAuthToken is not accessible, fallback to opening the link without the token. - // eslint-disable-next-line rulesdir/no-api-side-effects-method - API.makeRequestWithSideEffects('OpenOldDotLink', {}, {}) - .then((response) => { - buildOldDotURL(url, response.shortLivedAuthToken).then((oldDotUrl) => { - Linking.openURL(oldDotUrl); - }); - }) - .catch(() => { - buildOldDotURL(url).then((oldDotUrl) => { - Linking.openURL(oldDotUrl); - }); - }); + asyncOpenURL( + // eslint-disable-next-line rulesdir/no-api-side-effects-method + API.makeRequestWithSideEffects('OpenOldDotLink', {}, {}) + .then((response) => buildOldDotURL(url, response.shortLivedAuthToken)) + .catch(() => buildOldDotURL(url)), + (oldDotURL) => oldDotURL, + ); } /** diff --git a/src/libs/actions/Report.js b/src/libs/actions/Report.js index 2f6f812bdbe3..9027b8fb2f3c 100644 --- a/src/libs/actions/Report.js +++ b/src/libs/actions/Report.js @@ -1316,6 +1316,8 @@ function deleteReport(reportID) { * @param {String} reportID The reportID of the policy report (workspace room) */ function navigateToConciergeChatAndDeleteReport(reportID) { + // Dismiss the current report screen and replace it with Concierge Chat + Navigation.goBack(); navigateToConciergeChat(); deleteReport(reportID); } @@ -1633,12 +1635,18 @@ function removeEmojiReaction(reportID, originalReportAction, emoji) { /** * Calls either addEmojiReaction or removeEmojiReaction depending on if the current user has reacted to the report action. * @param {String} reportID - * @param {Object} reportAction + * @param {String} reportActionID * @param {Object} emoji * @param {number} paramSkinTone * @returns {Promise} */ -function toggleEmojiReaction(reportID, reportAction, emoji, paramSkinTone = preferredSkinTone) { +function toggleEmojiReaction(reportID, reportActionID, emoji, paramSkinTone = preferredSkinTone) { + const reportAction = ReportActionsUtils.getReportAction(reportID, reportActionID); + + if (_.isEmpty(reportAction)) { + return; + } + const message = reportAction.message[0]; const reactionObject = message.reactions && _.find(message.reactions, (reaction) => reaction.emoji === emoji.name); const skinTone = emoji.types === undefined ? null : paramSkinTone; // only use skin tone if emoji supports it diff --git a/src/libs/actions/Task.js b/src/libs/actions/Task.js index 59a84182b803..3d19a632a634 100644 --- a/src/libs/actions/Task.js +++ b/src/libs/actions/Task.js @@ -57,6 +57,7 @@ function createTaskAndNavigate(currentUserEmail, currentUserAccountID, parentRep // Create the CreatedReportAction on the task const optimisticTaskCreatedAction = ReportUtils.buildOptimisticCreatedReportAction(optimisticTaskReport.reportID); const optimisticAddCommentReport = ReportUtils.buildOptimisticTaskCommentReportAction(taskReportID, title, assignee, assigneeAccountID, `Created a task: ${title}`, parentReportID); + optimisticTaskReport.parentReportActionID = optimisticAddCommentReport.reportAction.reportActionID; const currentTime = DateUtils.getDBTime(); diff --git a/src/libs/actions/User.js b/src/libs/actions/User.js index 524444e08820..421b8781d84c 100644 --- a/src/libs/actions/User.js +++ b/src/libs/actions/User.js @@ -432,6 +432,9 @@ function validateSecondaryLogin(contactMethod, validateCode) { pendingFields: { validateLogin: null, }, + errorFields: { + validateCodeSent: null, + }, }, }, }, diff --git a/src/libs/migrations/PersonalDetailsByAccountID.js b/src/libs/migrations/PersonalDetailsByAccountID.js index 2872e0ed1afe..34b8ebabc150 100644 --- a/src/libs/migrations/PersonalDetailsByAccountID.js +++ b/src/libs/migrations/PersonalDetailsByAccountID.js @@ -225,6 +225,13 @@ export default function () { } }); + // The personalDetails object has been replaced by personalDetailsList + // So if we find an instance of personalDetails we will clear it out + if (oldPersonalDetails) { + Log.info('[Migrate Onyx] PersonalDetailsByAccountID migration: removing personalDetails'); + onyxData[DEPRECATED_ONYX_KEYS.PERSONAL_DETAILS] = null; + } + return Onyx.multiSet(onyxData); }, ); diff --git a/src/pages/NewChatPage.js b/src/pages/NewChatPage.js index 76a817acc334..8927088c3f67 100755 --- a/src/pages/NewChatPage.js +++ b/src/pages/NewChatPage.js @@ -1,5 +1,5 @@ import _ from 'underscore'; -import React, {Component} from 'react'; +import React, {useState, useEffect, useMemo} from 'react'; import {View} from 'react-native'; import PropTypes from 'prop-types'; import {withOnyx} from 'react-native-onyx'; @@ -44,80 +44,58 @@ const defaultProps = { reports: {}, }; -class NewChatPage extends Component { - constructor(props) { - super(props); - - this.toggleOption = this.toggleOption.bind(this); - this.createChat = this.createChat.bind(this); - this.createGroup = this.createGroup.bind(this); - this.updateOptionsWithSearchTerm = this.updateOptionsWithSearchTerm.bind(this); - this.excludedGroupEmails = _.without(CONST.EXPENSIFY_EMAILS, CONST.EMAIL.CONCIERGE); - - const {recentReports, personalDetails, userToInvite} = OptionsListUtils.getNewChatOptions( - props.reports, - props.personalDetails, - props.betas, - '', - [], - this.props.isGroupChat ? this.excludedGroupEmails : [], - ); - this.state = { - searchTerm: '', - recentReports, - personalDetails, - selectedOptions: [], - userToInvite, - }; - } - - componentDidUpdate(prevProps) { - if (_.isEqual(prevProps.reports, this.props.reports) && _.isEqual(prevProps.personalDetails, this.props.personalDetails)) { - return; - } - this.updateOptionsWithSearchTerm(this.state.searchTerm); - } - - /** - * Returns the sections needed for the OptionsSelector - * - * @param {Boolean} maxParticipantsReached - * @returns {Array} - */ - getSections(maxParticipantsReached) { - const sections = []; +const excludedGroupEmails = _.without(CONST.EXPENSIFY_EMAILS, CONST.EMAIL.CONCIERGE); + +function NewChatPage(props) { + const [searchTerm, setSearchTerm] = useState(''); + const [filteredRecentReports, setFilteredRecentReports] = useState([]); + const [filteredPersonalDetails, setFilteredPersonalDetails] = useState([]); + const [filteredUserToInvite, setFilteredUserToInvite] = useState(); + const [selectedOptions, setSelectedOptions] = useState([]); + + const maxParticipantsReached = selectedOptions.length === CONST.REPORT.MAXIMUM_PARTICIPANTS; + const headerMessage = OptionsListUtils.getHeaderMessage( + filteredPersonalDetails.length + filteredRecentReports.length !== 0, + Boolean(filteredUserToInvite), + searchTerm, + maxParticipantsReached, + ); + const isOptionsDataReady = ReportUtils.isReportDataReady() && OptionsListUtils.isPersonalDetailsReady(props.personalDetails); + + const sections = useMemo(() => { + const sectionsList = []; let indexOffset = 0; - if (this.props.isGroupChat) { - sections.push({ + if (props.isGroupChat) { + sectionsList.push({ title: undefined, - data: this.state.selectedOptions, - shouldShow: !_.isEmpty(this.state.selectedOptions), + data: selectedOptions, + shouldShow: !_.isEmpty(selectedOptions), indexOffset, }); - indexOffset += this.state.selectedOptions.length; + indexOffset += selectedOptions.length; if (maxParticipantsReached) { - return sections; + return sectionsList; } } // Filtering out selected users from the search results - const filterText = _.reduce(this.state.selectedOptions, (str, {login}) => `${str} ${login}`, ''); - const recentReportsWithoutSelected = _.filter(this.state.recentReports, ({login}) => !filterText.includes(login)); - const personalDetailsWithoutSelected = _.filter(this.state.personalDetails, ({login}) => !filterText.includes(login)); - const hasUnselectedUserToInvite = this.state.userToInvite && !filterText.includes(this.state.userToInvite.login); + const filterText = _.reduce(selectedOptions, (str, {login}) => `${str} ${login}`, ''); + const recentReportsWithoutSelected = _.filter(filteredRecentReports, ({login}) => !filterText.includes(login)); + const personalDetailsWithoutSelected = _.filter(filteredPersonalDetails, ({login}) => !filterText.includes(login)); + const hasUnselectedUserToInvite = filteredUserToInvite && !filterText.includes(filteredUserToInvite.login); - sections.push({ - title: this.props.translate('common.recents'), + sectionsList.push({ + title: props.translate('common.recents'), data: recentReportsWithoutSelected, shouldShow: !_.isEmpty(recentReportsWithoutSelected), indexOffset, }); indexOffset += recentReportsWithoutSelected.length; - sections.push({ - title: this.props.translate('common.contacts'), + sectionsList.push({ + title: props.translate('common.contacts'), data: personalDetailsWithoutSelected, shouldShow: !_.isEmpty(personalDetailsWithoutSelected), indexOffset, @@ -125,67 +103,39 @@ class NewChatPage extends Component { indexOffset += personalDetailsWithoutSelected.length; if (hasUnselectedUserToInvite) { - sections.push({ + sectionsList.push({ title: undefined, - data: [this.state.userToInvite], + data: [filteredUserToInvite], shouldShow: true, indexOffset, }); } - return sections; - } - - updateOptionsWithSearchTerm(searchTerm = '') { - const {recentReports, personalDetails, userToInvite} = OptionsListUtils.getNewChatOptions( - this.props.reports, - this.props.personalDetails, - this.props.betas, - searchTerm, - [], - this.props.isGroupChat ? this.excludedGroupEmails : [], - ); - this.setState({ - searchTerm, - userToInvite, - recentReports, - personalDetails, - }); - } + return sectionsList; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filteredPersonalDetails, filteredRecentReports, filteredUserToInvite, maxParticipantsReached, props.isGroupChat, selectedOptions]); /** * Removes a selected option from list if already selected. If not already selected add this option to the list. * @param {Object} option */ - toggleOption(option) { - this.setState((prevState) => { - const isOptionInList = _.some(prevState.selectedOptions, (selectedOption) => selectedOption.login === option.login); + function toggleOption(option) { + const isOptionInList = _.some(selectedOptions, (selectedOption) => selectedOption.login === option.login); - let newSelectedOptions; + let newSelectedOptions; - if (isOptionInList) { - newSelectedOptions = _.reject(prevState.selectedOptions, (selectedOption) => selectedOption.login === option.login); - } else { - newSelectedOptions = [...prevState.selectedOptions, option]; - } + if (isOptionInList) { + newSelectedOptions = _.reject(selectedOptions, (selectedOption) => selectedOption.login === option.login); + } else { + newSelectedOptions = [...selectedOptions, option]; + } - const {recentReports, personalDetails, userToInvite} = OptionsListUtils.getNewChatOptions( - this.props.reports, - this.props.personalDetails, - this.props.betas, - prevState.searchTerm, - [], - this.excludedGroupEmails, - ); + const {recentReports, personalDetails, userToInvite} = OptionsListUtils.getNewChatOptions(props.reports, props.personalDetails, props.betas, searchTerm, [], excludedGroupEmails); - return { - selectedOptions: newSelectedOptions, - recentReports, - personalDetails, - userToInvite, - searchTerm: prevState.searchTerm, - }; - }); + setSelectedOptions(newSelectedOptions); + setFilteredRecentReports(recentReports); + setFilteredPersonalDetails(personalDetails); + setFilteredUserToInvite(userToInvite); } /** @@ -194,7 +144,7 @@ class NewChatPage extends Component { * * @param {Object} option */ - createChat(option) { + function createChat(option) { Report.navigateToAndOpenReport([option.login]); } @@ -202,65 +152,69 @@ class NewChatPage extends Component { * Creates a new group chat with all the selected options and the current user, * or navigates to the existing chat if one with those participants already exists. */ - createGroup() { - if (!this.props.isGroupChat) { + const createGroup = () => { + if (!props.isGroupChat) { return; } - const logins = _.pluck(this.state.selectedOptions, 'login'); + const logins = _.pluck(selectedOptions, 'login'); if (logins.length < 1) { return; } Report.navigateToAndOpenReport(logins); - } + }; - render() { - const maxParticipantsReached = this.state.selectedOptions.length === CONST.REPORT.MAXIMUM_PARTICIPANTS; - const sections = this.getSections(maxParticipantsReached); - const headerMessage = OptionsListUtils.getHeaderMessage( - this.state.personalDetails.length + this.state.recentReports.length !== 0, - Boolean(this.state.userToInvite), - this.state.searchTerm, - maxParticipantsReached, - ); - const isOptionsDataReady = ReportUtils.isReportDataReady() && OptionsListUtils.isPersonalDetailsReady(this.props.personalDetails); - - return ( - - {({didScreenTransitionEnd, safeAreaPaddingBottomStyle}) => ( - <> - - - 0 ? safeAreaPaddingBottomStyle : {}]}> - (this.props.isGroupChat ? this.toggleOption(option) : this.createChat(option))} - onChangeText={this.updateOptionsWithSearchTerm} - headerMessage={headerMessage} - boldStyle - shouldFocusOnSelectRow={this.props.isGroupChat && !Browser.isMobile()} - shouldShowConfirmButton={this.props.isGroupChat} - shouldShowOptions={didScreenTransitionEnd && isOptionsDataReady} - confirmButtonText={this.props.translate('newChatPage.createGroup')} - onConfirmSelection={this.createGroup} - textInputLabel={this.props.translate('optionsSelector.nameEmailOrPhoneNumber')} - safeAreaPaddingBottomStyle={safeAreaPaddingBottomStyle} - /> - - - )} - + useEffect(() => { + const {recentReports, personalDetails, userToInvite} = OptionsListUtils.getNewChatOptions( + props.reports, + props.personalDetails, + props.betas, + searchTerm, + [], + props.isGroupChat ? excludedGroupEmails : [], ); - } + setFilteredRecentReports(recentReports); + setFilteredPersonalDetails(personalDetails); + setFilteredUserToInvite(userToInvite); + // props.betas and props.isGroupChat are not added as dependencies since they don't change during the component lifecycle + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [props.reports, props.personalDetails, searchTerm]); + + return ( + + {({didScreenTransitionEnd, safeAreaPaddingBottomStyle}) => ( + <> + + 0 ? safeAreaPaddingBottomStyle : {}]}> + (props.isGroupChat ? toggleOption(option) : createChat(option))} + onChangeText={setSearchTerm} + headerMessage={headerMessage} + boldStyle + shouldFocusOnSelectRow={props.isGroupChat && !Browser.isMobile()} + shouldShowConfirmButton={props.isGroupChat} + shouldShowOptions={didScreenTransitionEnd && isOptionsDataReady} + confirmButtonText={props.translate('newChatPage.createGroup')} + onConfirmSelection={createGroup} + textInputLabel={props.translate('optionsSelector.nameEmailOrPhoneNumber')} + safeAreaPaddingBottomStyle={safeAreaPaddingBottomStyle} + /> + + + )} + + ); } NewChatPage.propTypes = propTypes; NewChatPage.defaultProps = defaultProps; +NewChatPage.displayName = 'NewChatPage'; export default compose( withLocalize, diff --git a/src/pages/ReimbursementAccount/ContinueBankAccountSetup.js b/src/pages/ReimbursementAccount/ContinueBankAccountSetup.js index 1d0c3032d5b9..053cd3432473 100644 --- a/src/pages/ReimbursementAccount/ContinueBankAccountSetup.js +++ b/src/pages/ReimbursementAccount/ContinueBankAccountSetup.js @@ -51,7 +51,6 @@ function ContinueBankAccountSetup(props) { icon={Illustrations.BankArrow} > Link.buildOldDotURL(secureYourAccountUrl), }, diff --git a/src/pages/ReportDetailsPage.js b/src/pages/ReportDetailsPage.js index 26588481f53a..cb727d9e3eae 100644 --- a/src/pages/ReportDetailsPage.js +++ b/src/pages/ReportDetailsPage.js @@ -63,7 +63,7 @@ function ReportDetailsPage(props) { const isPolicyAdmin = useMemo(() => PolicyUtils.isPolicyAdmin(policy), [policy]); const isPolicyExpenseChat = useMemo(() => ReportUtils.isPolicyExpenseChat(props.report), [props.report]); const isChatRoom = useMemo(() => ReportUtils.isChatRoom(props.report), [props.report]); - const isThread = useMemo(() => ReportUtils.isThread(props.report), [props.report]); + const isThread = useMemo(() => ReportUtils.isChatThread(props.report), [props.report]); const isUserCreatedPolicyRoom = useMemo(() => ReportUtils.isUserCreatedPolicyRoom(props.report), [props.report]); const isArchivedRoom = useMemo(() => ReportUtils.isArchivedRoom(props.report), [props.report]); diff --git a/src/pages/ReportParticipantsPage.js b/src/pages/ReportParticipantsPage.js index cdcaabcfe34d..6d9bdbffba54 100755 --- a/src/pages/ReportParticipantsPage.js +++ b/src/pages/ReportParticipantsPage.js @@ -93,7 +93,9 @@ function ReportParticipantsPage(props) { 1; const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips(participantPersonalDetails, isMultipleParticipant); - const isThread = ReportUtils.isThread(props.report); + const isChatThread = ReportUtils.isChatThread(props.report); const isChatRoom = ReportUtils.isChatRoom(props.report); const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(props.report); const isTaskReport = ReportUtils.isTaskReport(props.report); - const reportHeaderData = !isTaskReport && !isThread && props.report.parentReportID ? props.parentReport : props.report; + const reportHeaderData = !isTaskReport && !isChatThread && props.report.parentReportID ? props.parentReport : props.report; const title = ReportUtils.getReportName(reportHeaderData); const subtitle = ReportUtils.getChatRoomSubtitle(reportHeaderData); + const parentNavigationSubtitle = ReportUtils.getParentNavigationSubtitle(reportHeaderData); const isConcierge = participants.length === 1 && _.contains(participants, CONST.ACCOUNT_ID.CONCIERGE); const isAutomatedExpensifyAccount = participants.length === 1 && ReportUtils.hasAutomatedExpensifyAccountIDs(participants); const guideCalendarLink = lodashGet(props.account, 'guideCalendarLink'); @@ -174,7 +175,7 @@ function HeaderView(props) { ) : ( )} @@ -184,35 +185,31 @@ function HeaderView(props) { tooltipEnabled numberOfLines={1} textStyles={[styles.headerText, styles.pre]} - shouldUseFullTitle={isChatRoom || isPolicyExpenseChat || isThread || isTaskReport} + shouldUseFullTitle={isChatRoom || isPolicyExpenseChat || isChatThread || isTaskReport} /> - {(isChatRoom || isPolicyExpenseChat || isThread) && !_.isEmpty(subtitle) && ( - <> - {isThread ? ( - { - Navigation.navigate(ROUTES.getReportRoute(props.report.parentReportID)); - }} - style={[styles.alignSelfStart, styles.mw100]} - accessibilityLabel={subtitle} - accessibilityRole="link" - > - - {subtitle} - - - ) : ( - - {subtitle} - - )} - + {!_.isEmpty(parentNavigationSubtitle) && ( + { + Navigation.navigate(ROUTES.getReportRoute(props.report.parentReportID)); + }} + accessibilityLabel={parentNavigationSubtitle} + accessibilityRole="link" + > + + {parentNavigationSubtitle} + + + )} + {!_.isEmpty(subtitle) && ( + + {subtitle} + )} {brickRoadIndicator === CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR && ( diff --git a/src/pages/home/report/ContextMenu/ContextMenuActions.js b/src/pages/home/report/ContextMenu/ContextMenuActions.js index 0a0388e414c7..b05c29804d19 100644 --- a/src/pages/home/report/ContextMenu/ContextMenuActions.js +++ b/src/pages/home/report/ContextMenu/ContextMenuActions.js @@ -58,7 +58,7 @@ export default [ }; const onEmojiSelected = (emoji) => { - Report.toggleEmojiReaction(reportID, reportAction, emoji); + Report.toggleEmojiReaction(reportID, reportAction.reportActionID, emoji); closeContextMenu(); }; diff --git a/src/pages/home/report/ReportActionItem.js b/src/pages/home/report/ReportActionItem.js index cc7f4537228d..2afd28f934fe 100644 --- a/src/pages/home/report/ReportActionItem.js +++ b/src/pages/home/report/ReportActionItem.js @@ -148,26 +148,18 @@ function ReportActionItem(props) { // Hide the message if it is being moderated for a higher offense, or is hidden by a moderator // Removed messages should not be shown anyway and should not need this flow - + const decisions = lodashGet(props, ['action', 'message', 0, 'moderationDecisions'], []); + const latestDecision = lodashGet(_.last(decisions), 'decision', ''); useEffect(() => { - if (!props.action.actionName === CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT || _.isEmpty(props.action.message[0].moderationDecisions)) { + if (!props.action.actionName === CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT || _.isEmpty(latestDecision)) { return; } - // Right now we are only sending the latest moderationDecision to the frontend even though it is an array - let decisions = props.action.message[0].moderationDecisions; - if (decisions.length > 1) { - decisions = decisions.slice(-1); - } - const latestDecision = decisions[0]; - if (latestDecision.decision === CONST.MODERATION.MODERATOR_DECISION_PENDING_HIDE || latestDecision.decision === CONST.MODERATION.MODERATOR_DECISION_HIDDEN) { + if (latestDecision === CONST.MODERATION.MODERATOR_DECISION_PENDING_HIDE || latestDecision === CONST.MODERATION.MODERATOR_DECISION_HIDDEN) { setIsHidden(true); } - setModerationDecision(latestDecision.decision); - - // props.action.message doesn't need to be a dependency, we only need to check the change of props.action.message[0].moderationDecisions - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [props.action.message[0].moderationDecisions, props.action.actionName]); + setModerationDecision(latestDecision); + }, [latestDecision, props.action.actionName]); const toggleContextMenuFromActiveReportAction = useCallback(() => { setIsContextMenuActive(ReportActionContextMenu.isActiveReportAction(props.action.reportActionID)); @@ -207,7 +199,7 @@ function ReportActionItem(props) { const toggleReaction = useCallback( (emoji) => { - Report.toggleEmojiReaction(props.report.reportID, props.action, emoji); + Report.toggleEmojiReaction(props.report.reportID, props.action.reportActionID, emoji); }, [props.report, props.action], ); diff --git a/src/pages/home/report/ReportActionsList.js b/src/pages/home/report/ReportActionsList.js index 8e48f06c3528..b0f108ed7d39 100644 --- a/src/pages/home/report/ReportActionsList.js +++ b/src/pages/home/report/ReportActionsList.js @@ -116,7 +116,7 @@ function ReportActionsList(props) { ({item: reportAction, index}) => { // When the new indicator should not be displayed we explicitly set it to null const shouldDisplayNewMarker = reportAction.reportActionID === newMarkerReportActionID; - const shouldDisplayParentAction = reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED && ReportUtils.isThread(report); + const shouldDisplayParentAction = reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED && ReportUtils.isChatThread(report); const shouldHideThreadDividerLine = shouldDisplayParentAction && sortedReportActions.length > 1 && sortedReportActions[sortedReportActions.length - 2].reportActionID === newMarkerReportActionID; return shouldDisplayParentAction ? ( diff --git a/src/pages/home/report/ReportActionsView.js b/src/pages/home/report/ReportActionsView.js index fdd255f05006..f3c9e67d79db 100755 --- a/src/pages/home/report/ReportActionsView.js +++ b/src/pages/home/report/ReportActionsView.js @@ -2,6 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import _ from 'underscore'; import lodashGet from 'lodash/get'; +import lodashCloneDeep from 'lodash/cloneDeep'; import * as Report from '../../../libs/actions/Report'; import reportActionPropTypes from './reportActionPropTypes'; import Visibility from '../../../libs/Visibility'; @@ -218,6 +219,21 @@ class ReportActionsView extends React.Component { }); } + // If the last unread message was deleted, remove the *New* green marker and the *New Messages* notification at scroll just as the deletion starts. + if ( + ReportUtils.isUnread(this.props.report) && + this.props.reportActions.length > 0 && + this.props.reportActions[0].pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && + !this.props.network.isOffline + ) { + const reportActionsWithoutPendingOne = lodashCloneDeep(this.props.reportActions); + reportActionsWithoutPendingOne.shift(); + const newMarkerReportActionID = ReportUtils.getNewMarkerReportActionID(this.props.report, reportActionsWithoutPendingOne); + if (newMarkerReportActionID !== this.state.newMarkerReportActionID) { + this.setState({newMarkerReportActionID}); + } + } + // Checks to see if a report comment has been manually "marked as unread". All other times when the lastReadTime // changes it will be because we marked the entire report as read. const didManuallyMarkReportAsUnread = prevProps.report.lastReadTime !== this.props.report.lastReadTime && ReportUtils.isUnread(this.props.report); diff --git a/src/pages/home/report/ReportAttachments.js b/src/pages/home/report/ReportAttachments.js new file mode 100644 index 000000000000..38e7781aedf5 --- /dev/null +++ b/src/pages/home/report/ReportAttachments.js @@ -0,0 +1,40 @@ +import React from 'react'; +import _ from 'underscore'; +import PropTypes from 'prop-types'; +import AttachmentModal from '../../../components/AttachmentModal'; +import Navigation from '../../../libs/Navigation/Navigation'; +import * as ReportUtils from '../../../libs/ReportUtils'; + +const propTypes = { + /** Navigation route context info provided by react navigation */ + route: PropTypes.shape({ + /** Route specific parameters used on this screen */ + params: PropTypes.shape({ + /** The report ID which the attachment is associated with */ + reportID: PropTypes.string.isRequired, + /** The uri encoded source of the attachment */ + source: PropTypes.string.isRequired, + }).isRequired, + }).isRequired, +}; + +function ReportAttachments(props) { + const reportID = _.get(props, ['route', 'params', 'reportID']); + const report = ReportUtils.getReport(reportID); + const source = decodeURI(_.get(props, ['route', 'params', 'source'])); + + return ( + Navigation.dismissModal(reportID)} + /> + ); +} + +ReportAttachments.propTypes = propTypes; +ReportAttachments.displayName = 'ReportAttachments'; + +export default ReportAttachments; diff --git a/src/pages/iou/IOUCurrencySelection.js b/src/pages/iou/IOUCurrencySelection.js index 20c1a58b3fed..08185e0420ab 100644 --- a/src/pages/iou/IOUCurrencySelection.js +++ b/src/pages/iou/IOUCurrencySelection.js @@ -1,4 +1,4 @@ -import React, {Component} from 'react'; +import React, {useState, useMemo, useCallback} from 'react'; import PropTypes from 'prop-types'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; @@ -53,50 +53,29 @@ const defaultProps = { }, }; -class IOUCurrencySelection extends Component { - constructor(props) { - super(props); - - this.state = { - searchValue: '', - currencyData: this.getCurrencyOptions(this.props.currencyList), - }; - this.getCurrencyOptions = this.getCurrencyOptions.bind(this); - this.getSections = this.getSections.bind(this); - this.confirmCurrencySelection = this.confirmCurrencySelection.bind(this); - this.changeSearchValue = this.changeSearchValue.bind(this); - } - - /** - * Returns the sections needed for the OptionsSelector - * - * @returns {Array} - */ - getSections() { - if (this.state.searchValue.trim() && !this.state.currencyData.length) { - return []; - } - const sections = []; - sections.push({ - title: this.props.translate('iOUCurrencySelection.allCurrencies'), - data: this.state.currencyData, - shouldShow: true, - indexOffset: 0, - }); - - return sections; - } - - getSelectedCurrencyCode() { - return lodashGet(this.props.route, 'params.currency', this.props.iou.currency); - } - - /** - * @returns {Object} - */ - getCurrencyOptions() { - return _.map(this.props.currencyList, (currencyInfo, currencyCode) => { - const isSelectedCurrency = currencyCode === this.getSelectedCurrencyCode(); +function IOUCurrencySelection(props) { + const [searchValue, setSearchValue] = useState(''); + const selectedCurrencyCode = lodashGet(props.route, 'params.currency', props.iou.currency, CONST.CURRENCY.USD); + + const confirmCurrencySelection = useCallback( + (option) => { + const backTo = lodashGet(props.route, 'params.backTo', ''); + // When we refresh the web, the money request route gets cleared from the navigation stack. + // Navigating to "backTo" will result in forward navigation instead, causing disruption to the currency selection. + // To prevent any negative experience, we have made the decision to simply close the currency selection page. + if (_.isEmpty(backTo) || props.navigation.getState().routes.length === 1) { + Navigation.goBack(); + } else { + Navigation.navigate(`${props.route.params.backTo}?currency=${option.currencyCode}`); + } + }, + [props.route, props.navigation], + ); + + const {translate, currencyList} = props; + const {sections, headerMessage, initiallyFocusedOptionKey} = useMemo(() => { + const currencyOptions = _.map(currencyList, (currencyInfo, currencyCode) => { + const isSelectedCurrency = currencyCode === selectedCurrencyCode; return { text: `${currencyCode} - ${CurrencyUtils.getLocalizedCurrencySymbol(currencyCode)}`, currencyCode, @@ -105,75 +84,56 @@ class IOUCurrencySelection extends Component { boldStyle: isSelectedCurrency, }; }); - } - /** - * Sets new search value - * @param {String} searchValue - * @return {void} - */ - changeSearchValue(searchValue) { - const currencyOptions = this.getCurrencyOptions(this.props.currencyList); const searchRegex = new RegExp(Str.escapeForRegExp(searchValue), 'i'); const filteredCurrencies = _.filter(currencyOptions, (currencyOption) => searchRegex.test(currencyOption.text)); - - this.setState({ - searchValue, - currencyData: filteredCurrencies, - }); - } - - /** - * Confirms the selection of currency - * - * @param {Object} option - * @param {String} option.currencyCode - */ - confirmCurrencySelection(option) { - const backTo = lodashGet(this.props.route, 'params.backTo', ''); - // When we refresh the web, the money request route gets cleared from the navigation stack. - // Navigating to "backTo" will result in forward navigation instead, causing disruption to the currency selection. - // To prevent any negative experience, we have made the decision to simply close the currency selection page. - if (_.isEmpty(backTo) || this.props.navigation.getState().routes.length === 1) { - Navigation.goBack(); - } else { - Navigation.navigate(`${this.props.route.params.backTo}?currency=${option.currencyCode}`); - } - } - - render() { - const headerMessage = this.state.searchValue.trim() && !this.state.currencyData.length ? this.props.translate('common.noResultsFound') : ''; - const iouType = lodashGet(this.props.route, 'params.iouType', CONST.IOU.MONEY_REQUEST_TYPE.REQUEST); - const reportID = lodashGet(this.props.route, 'params.reportID', ''); - return ( - - {({safeAreaPaddingBottomStyle}) => ( - <> - Navigation.goBack(ROUTES.getMoneyRequestRoute(iouType, reportID))} - /> - currency.currencyCode === this.getSelectedCurrencyCode()), - 'keyForList', - )} - shouldHaveOptionSeparator - /> - - )} - - ); - } + const isEmpty = searchValue.trim() && !filteredCurrencies.length; + + return { + initiallyFocusedOptionKey: _.get( + _.find(filteredCurrencies, (currency) => currency.currencyCode === selectedCurrencyCode), + 'keyForList', + ), + sections: isEmpty + ? [] + : [ + { + title: translate('iOUCurrencySelection.allCurrencies'), + data: filteredCurrencies, + shouldShow: true, + indexOffset: 0, + }, + ], + headerMessage: isEmpty ? translate('common.noResultsFound') : '', + }; + }, [currencyList, searchValue, selectedCurrencyCode, translate]); + + return ( + + {({safeAreaPaddingBottomStyle}) => ( + <> + Navigation.goBack(ROUTES.getIouRequestRoute(Navigation.getTopmostReportId()))} + /> + + + )} + + ); } +IOUCurrencySelection.displayName = 'IOUCurrencySelection'; IOUCurrencySelection.propTypes = propTypes; IOUCurrencySelection.defaultProps = defaultProps; diff --git a/src/pages/settings/InitialSettingsPage.js b/src/pages/settings/InitialSettingsPage.js index b7364ce623fa..97d013f31084 100755 --- a/src/pages/settings/InitialSettingsPage.js +++ b/src/pages/settings/InitialSettingsPage.js @@ -21,7 +21,6 @@ import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize import compose from '../../libs/compose'; import CONST from '../../CONST'; import Permissions from '../../libs/Permissions'; -import * as App from '../../libs/actions/App'; import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsPropTypes, withCurrentUserPersonalDetailsDefaultProps} from '../../components/withCurrentUserPersonalDetails'; import * as PaymentMethods from '../../libs/actions/PaymentMethods'; import bankAccountPropTypes from '../../components/bankAccountPropTypes'; @@ -200,7 +199,7 @@ class InitialSettingsPage extends React.Component { translationKey: 'common.profile', icon: Expensicons.Profile, action: () => { - App.openProfile(); + Navigation.navigate(ROUTES.SETTINGS_PROFILE); }, brickRoadIndicator: profileBrickRoadIndicator, }, diff --git a/src/pages/settings/Payments/AddDebitCardPage.js b/src/pages/settings/Payments/AddDebitCardPage.js index 65be895baee3..87b3a774ef85 100644 --- a/src/pages/settings/Payments/AddDebitCardPage.js +++ b/src/pages/settings/Payments/AddDebitCardPage.js @@ -73,7 +73,7 @@ class DebitCardPage extends Component { validate(values) { const errors = {}; - if (!values.nameOnCard || !ValidationUtils.isValidCardName(values.nameOnCard)) { + if (!values.nameOnCard || !ValidationUtils.isValidLegalName(values.nameOnCard)) { errors.nameOnCard = 'addDebitCardPage.error.invalidName'; } diff --git a/src/pages/settings/Profile/ProfilePage.js b/src/pages/settings/Profile/ProfilePage.js index 1fd1e585648d..07db3d0cdffb 100755 --- a/src/pages/settings/Profile/ProfilePage.js +++ b/src/pages/settings/Profile/ProfilePage.js @@ -1,5 +1,5 @@ import lodashGet from 'lodash/get'; -import React from 'react'; +import React, {useEffect} from 'react'; import {View} from 'react-native'; import PropTypes from 'prop-types'; import {withOnyx} from 'react-native-onyx'; @@ -23,6 +23,7 @@ import * as Expensicons from '../../../components/Icon/Expensicons'; import ONYXKEYS from '../../../ONYXKEYS'; import withWindowDimensions, {windowDimensionsPropTypes} from '../../../components/withWindowDimensions'; import userPropTypes from '../userPropTypes'; +import * as App from '../../../libs/actions/App'; const propTypes = { /* Onyx Props */ @@ -84,6 +85,10 @@ function ProfilePage(props) { }, ]; + useEffect(() => { + App.openProfile(props.currentUserPersonalDetails); + }, [props.currentUserPersonalDetails]); + return ( policy && policy.id === this.props.report.policyID); - const shouldDisableRename = this.shouldDisableRename(linkedWorkspace) || ReportUtils.isThread(this.props.report); + const shouldDisableRename = this.shouldDisableRename(linkedWorkspace) || ReportUtils.isChatThread(this.props.report); const notificationPreference = this.props.translate(`notificationPreferencesPage.notificationPreferences.${this.props.report.notificationPreference}`); const shouldDisableWelcomeMessage = this.shouldDisableWelcomeMessage(linkedWorkspace); const writeCapability = this.props.report.writeCapability || CONST.REPORT.WRITE_CAPABILITIES.ALL; @@ -119,6 +119,7 @@ class ReportSettingsPage extends Component { Report.clearPolicyRoomNameErrors(this.props.report.reportID)} > {shouldDisableRename ? ( diff --git a/src/pages/settings/Security/TwoFactorAuth/CodesPage.js b/src/pages/settings/Security/TwoFactorAuth/CodesPage.js index 91ccee630879..39d4141a2872 100644 --- a/src/pages/settings/Security/TwoFactorAuth/CodesPage.js +++ b/src/pages/settings/Security/TwoFactorAuth/CodesPage.js @@ -25,7 +25,6 @@ import Clipboard from '../../../../libs/Clipboard'; import themeColors from '../../../../styles/themes/default'; import localFileDownload from '../../../../libs/localFileDownload'; import * as TwoFactorAuthActions from '../../../../libs/actions/TwoFactorAuthActions'; -import * as StyleUtils from '../../../../styles/StyleUtils'; const propTypes = { ...withLocalizePropTypes, @@ -96,9 +95,9 @@ function CodesPage(props) { ))} - + Clipboard.setString(props.account.twoFactorAuthSecretKey)} - styles={[styles.button, styles.buttonMedium]} + styles={[styles.button, styles.buttonMedium, styles.twoFactorAuthCopyCodeButton]} textStyles={[styles.buttonMediumText]} /> diff --git a/src/pages/signin/SignInPage.js b/src/pages/signin/SignInPage.js index a875c25359b0..8c3d513f5303 100644 --- a/src/pages/signin/SignInPage.js +++ b/src/pages/signin/SignInPage.js @@ -1,30 +1,27 @@ -import React, {Component} from 'react'; +import React, {useEffect} from 'react'; import PropTypes from 'prop-types'; import {withOnyx} from 'react-native-onyx'; import {View} from 'react-native'; -import lodashGet from 'lodash/get'; import Str from 'expensify-common/lib/str'; -import {withSafeAreaInsets} from 'react-native-safe-area-context'; +import {useSafeAreaInsets} from 'react-native-safe-area-context'; import ONYXKEYS from '../../ONYXKEYS'; import styles from '../../styles/styles'; -import compose from '../../libs/compose'; import SignInPageLayout from './SignInPageLayout'; import LoginForm from './LoginForm'; import PasswordForm from './PasswordForm'; import ValidateCodeForm from './ValidateCodeForm'; import ResendValidationForm from './ResendValidationForm'; -import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize'; import Performance from '../../libs/Performance'; import * as App from '../../libs/actions/App'; -import Permissions from '../../libs/Permissions'; import UnlinkLoginForm from './UnlinkLoginForm'; -import withWindowDimensions, {windowDimensionsPropTypes} from '../../components/withWindowDimensions'; import * as Localize from '../../libs/Localize'; import * as StyleUtils from '../../styles/StyleUtils'; +import useLocalize from '../../hooks/useLocalize'; +import usePermissions from '../../hooks/usePermissions'; +import useWindowDimensions from '../../hooks/useWindowDimensions'; +import Log from '../../libs/Log'; const propTypes = { - /* Onyx Props */ - /** The details about the account that the user is signing in with */ account: PropTypes.shape({ /** Error to display when there is an account error returned */ @@ -35,153 +32,149 @@ const propTypes = { /** The primaryLogin associated with the account */ primaryLogin: PropTypes.string, - }), - /** List of betas available to current user */ - betas: PropTypes.arrayOf(PropTypes.string), + /** Has the user pressed the forgot password button? */ + forgotPassword: PropTypes.bool, + + /** Does this account require 2FA? */ + requiresTwoFactorAuth: PropTypes.bool, + }), /** The credentials of the person signing in */ credentials: PropTypes.shape({ login: PropTypes.string, password: PropTypes.string, twoFactorAuthCode: PropTypes.string, + validateCode: PropTypes.string, }), - - ...withLocalizePropTypes, - - ...windowDimensionsPropTypes, }; const defaultProps = { account: {}, - betas: [], credentials: {}, }; -class SignInPage extends Component { - componentDidMount() { - Performance.measureTTI(); +/** + * @param {Boolean} hasLogin + * @param {Boolean} hasPassword + * @param {Boolean} hasValidateCode + * @param {Boolean} isPrimaryLogin + * @param {Boolean} isAccountValidated + * @param {Boolean} didForgetPassword + * @param {Boolean} canUsePasswordlessLogins + * @returns {Object} + */ +function getRenderOptions({hasLogin, hasPassword, hasValidateCode, isPrimaryLogin, isAccountValidated, didForgetPassword, canUsePasswordlessLogins}) { + const shouldShowLoginForm = !hasLogin && !hasValidateCode; + const isUnvalidatedSecondaryLogin = hasLogin && !isPrimaryLogin && !isAccountValidated; + const shouldShowPasswordForm = hasLogin && isAccountValidated && !hasPassword && !didForgetPassword && !isUnvalidatedSecondaryLogin && !canUsePasswordlessLogins; + const shouldShowValidateCodeForm = (hasLogin || hasValidateCode) && !isUnvalidatedSecondaryLogin && canUsePasswordlessLogins; + const shouldShowResendValidationForm = hasLogin && (!isAccountValidated || didForgetPassword) && !isUnvalidatedSecondaryLogin && !canUsePasswordlessLogins; + const shouldShowWelcomeHeader = shouldShowLoginForm || shouldShowPasswordForm || shouldShowValidateCodeForm || isUnvalidatedSecondaryLogin; + const shouldShowWelcomeText = shouldShowLoginForm || shouldShowPasswordForm || shouldShowValidateCodeForm; + return { + shouldShowLoginForm, + shouldShowUnlinkLoginForm: isUnvalidatedSecondaryLogin, + shouldShowPasswordForm, + shouldShowValidateCodeForm, + shouldShowResendValidationForm, + shouldShowWelcomeHeader, + shouldShowWelcomeText, + }; +} - App.setLocale(Localize.getDevicePreferredLocale()); - } +function SignInPage({credentials, account}) { + const {translate, formatPhoneNumber} = useLocalize(); + const {canUsePasswordlessLogins} = usePermissions(); + const {isSmallScreenWidth} = useWindowDimensions(); + const safeAreaInsets = useSafeAreaInsets(); - render() { - // Show the login form if - // - A login has not been entered yet - // - AND a validateCode has not been cached with sign in link - const showLoginForm = !this.props.credentials.login && !this.props.credentials.validateCode; - - // Show the unlink form if - // - A login has been entered - // - AND the login is not the primary login - // - AND the login is not validated - const showUnlinkLoginForm = - Boolean(this.props.credentials.login && this.props.account.primaryLogin) && this.props.account.primaryLogin !== this.props.credentials.login && !this.props.account.validated; - - // Show the old password form if - // - A login has been entered - // - AND an account exists and is validated for this login - // - AND a password hasn't been entered yet - // - AND haven't forgotten password - // - AND the login isn't an unvalidated secondary login - // - AND the user is NOT on the passwordless beta - const showPasswordForm = - Boolean(this.props.credentials.login) && - this.props.account.validated && - !this.props.credentials.password && - !this.props.account.forgotPassword && - !showUnlinkLoginForm && - !Permissions.canUsePasswordlessLogins(this.props.betas); - - // Show the new magic code / validate code form if - // - A login has been entered or a validateCode has been cached from sign in link - // - AND the login isn't an unvalidated secondary login - // - AND the user is on the 'passwordless' beta - const showValidateCodeForm = - Boolean(this.props.credentials.login || this.props.credentials.validateCode) && !showUnlinkLoginForm && Permissions.canUsePasswordlessLogins(this.props.betas); - - // Show the resend validation link form if - // - A login has been entered - // - AND is not validated or password is forgotten - // - AND the login isn't an unvalidated secondary login - // - AND user is not on 'passwordless' beta - const showResendValidationForm = - Boolean(this.props.credentials.login) && - (!this.props.account.validated || this.props.account.forgotPassword) && - !showUnlinkLoginForm && - !Permissions.canUsePasswordlessLogins(this.props.betas); - - let welcomeHeader = ''; - let welcomeText = ''; - if (showValidateCodeForm) { - if (this.props.account.requiresTwoFactorAuth) { - // We will only know this after a user signs in successfully, without their 2FA code - welcomeHeader = this.props.isSmallScreenWidth ? '' : this.props.translate('welcomeText.welcomeBack'); - welcomeText = this.props.translate('validateCodeForm.enterAuthenticatorCode'); + useEffect(() => Performance.measureTTI(), []); + useEffect(() => { + App.setLocale(Localize.getDevicePreferredLocale()); + }, []); + + const { + shouldShowLoginForm, + shouldShowUnlinkLoginForm, + shouldShowPasswordForm, + shouldShowValidateCodeForm, + shouldShowResendValidationForm, + shouldShowWelcomeHeader, + shouldShowWelcomeText, + } = getRenderOptions({ + hasLogin: Boolean(credentials.login), + hasPassword: Boolean(credentials.password), + hasValidateCode: Boolean(credentials.validateCode), + isPrimaryLogin: !account.primaryLogin || account.primaryLogin === credentials.login, + isAccountValidated: Boolean(account.validated), + didForgetPassword: Boolean(account.forgotPassword), + canUsePasswordlessLogins, + }); + + let welcomeHeader; + let welcomeText; + if (shouldShowValidateCodeForm) { + if (account.requiresTwoFactorAuth) { + // We will only know this after a user signs in successfully, without their 2FA code + welcomeHeader = isSmallScreenWidth ? '' : translate('welcomeText.welcomeBack'); + welcomeText = translate('validateCodeForm.enterAuthenticatorCode'); + } else { + const userLogin = Str.removeSMSDomain(credentials.login || ''); + + // replacing spaces with "hard spaces" to prevent breaking the number + const userLoginToDisplay = Str.isSMSLogin(userLogin) ? formatPhoneNumber(userLogin).replace(/ /g, '\u00A0') : userLogin; + if (account.validated) { + welcomeHeader = isSmallScreenWidth ? '' : translate('welcomeText.welcomeBack'); + welcomeText = isSmallScreenWidth + ? `${translate('welcomeText.welcomeBack')} ${translate('welcomeText.welcomeEnterMagicCode', {login: userLoginToDisplay})}` + : translate('welcomeText.welcomeEnterMagicCode', {login: userLoginToDisplay}); } else { - const userLogin = Str.removeSMSDomain(lodashGet(this.props, 'credentials.login', '')); - - // replacing spaces with "hard spaces" to prevent breaking the number - const userLoginToDisplay = Str.isSMSLogin(userLogin) ? this.props.formatPhoneNumber(userLogin).replace(/ /g, '\u00A0') : userLogin; - if (this.props.account.validated) { - welcomeHeader = this.props.isSmallScreenWidth ? '' : this.props.translate('welcomeText.welcomeBack'); - welcomeText = this.props.isSmallScreenWidth - ? `${this.props.translate('welcomeText.welcomeBack')} ${this.props.translate('welcomeText.welcomeEnterMagicCode', {login: userLoginToDisplay})}` - : this.props.translate('welcomeText.welcomeEnterMagicCode', {login: userLoginToDisplay}); - } else { - welcomeHeader = this.props.isSmallScreenWidth ? '' : this.props.translate('welcomeText.welcome'); - welcomeText = this.props.isSmallScreenWidth - ? `${this.props.translate('welcomeText.welcome')} ${this.props.translate('welcomeText.newFaceEnterMagicCode', {login: userLoginToDisplay})}` - : this.props.translate('welcomeText.newFaceEnterMagicCode', {login: userLoginToDisplay}); - } + welcomeHeader = isSmallScreenWidth ? '' : translate('welcomeText.welcome'); + welcomeText = isSmallScreenWidth + ? `${translate('welcomeText.welcome')} ${translate('welcomeText.newFaceEnterMagicCode', {login: userLoginToDisplay})}` + : translate('welcomeText.newFaceEnterMagicCode', {login: userLoginToDisplay}); } - } else if (showPasswordForm) { - welcomeHeader = this.props.isSmallScreenWidth ? '' : this.props.translate('welcomeText.welcomeBack'); - welcomeText = this.props.isSmallScreenWidth - ? `${this.props.translate('welcomeText.welcomeBack')} ${this.props.translate('welcomeText.enterPassword')}` - : this.props.translate('welcomeText.enterPassword'); - } else if (showUnlinkLoginForm) { - welcomeHeader = this.props.isSmallScreenWidth ? this.props.translate('login.hero.header') : this.props.translate('welcomeText.welcomeBack'); - } else if (!showResendValidationForm) { - welcomeHeader = this.props.isSmallScreenWidth ? this.props.translate('login.hero.header') : this.props.translate('welcomeText.getStarted'); - welcomeText = this.props.isSmallScreenWidth ? this.props.translate('welcomeText.getStarted') : ''; } + } else if (shouldShowPasswordForm) { + welcomeHeader = isSmallScreenWidth ? '' : translate('welcomeText.welcomeBack'); + welcomeText = isSmallScreenWidth ? `${translate('welcomeText.welcomeBack')} ${translate('welcomeText.enterPassword')}` : translate('welcomeText.enterPassword'); + } else if (shouldShowUnlinkLoginForm) { + welcomeHeader = isSmallScreenWidth ? translate('login.hero.header') : translate('welcomeText.welcomeBack'); + } else if (!shouldShowResendValidationForm) { + welcomeHeader = isSmallScreenWidth ? translate('login.hero.header') : translate('welcomeText.getStarted'); + welcomeText = isSmallScreenWidth ? translate('welcomeText.getStarted') : ''; + } else { + Log.warn('SignInPage in unexpected state!'); + } - return ( - // There is an issue SafeAreaView on Android where wrong insets flicker on app start. - // Can be removed once https://github.com/th3rdwave/react-native-safe-area-context/issues/364 is resolved. - - - {/* LoginForm and PasswordForm must use the isVisible prop. This keeps them mounted, but visually hidden + return ( + + + {/* LoginForm and PasswordForm must use the isVisible prop. This keeps them mounted, but visually hidden so that password managers can access the values. Conditionally rendering these components will break this feature. */} - - {showValidateCodeForm ? : } - {showResendValidationForm && } - {showUnlinkLoginForm && } - - - ); - } + + {shouldShowValidateCodeForm ? : } + {shouldShowResendValidationForm && } + {shouldShowUnlinkLoginForm && } + + + ); } SignInPage.propTypes = propTypes; SignInPage.defaultProps = defaultProps; +SignInPage.displayName = 'SignInPage'; -export default compose( - withSafeAreaInsets, - withLocalize, - withWindowDimensions, - withOnyx({ - account: {key: ONYXKEYS.ACCOUNT}, - betas: {key: ONYXKEYS.BETAS}, - credentials: {key: ONYXKEYS.CREDENTIALS}, - }), -)(SignInPage); +export default withOnyx({ + account: {key: ONYXKEYS.ACCOUNT}, + credentials: {key: ONYXKEYS.CREDENTIALS}, +})(SignInPage); diff --git a/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js b/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js index abee3ce0f25b..83a4d0afbaa5 100755 --- a/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js +++ b/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js @@ -46,6 +46,12 @@ const propTypes = { login: PropTypes.string, }), + /** Session of currently logged in user */ + session: PropTypes.shape({ + /** Currently logged in user authToken */ + authToken: PropTypes.string, + }), + /** Indicates which locale the user currently has selected */ preferredLocale: PropTypes.string, @@ -62,6 +68,9 @@ const propTypes = { const defaultProps = { account: {}, credentials: {}, + session: { + authToken: null, + }, preferredLocale: CONST.LOCALES.DEFAULT, }; @@ -79,6 +88,15 @@ function BaseValidateCodeForm(props) { const input2FARef = useRef(); const timerRef = useRef(); + const hasError = Boolean(props.account) && !_.isEmpty(props.account.errors); + + useEffect(() => { + if (!(inputValidateCodeRef.current && ((hasError && props.session.autoAuthState === CONST.AUTO_AUTH_STATE.FAILED) || props.account.isLoading))) { + return; + } + inputValidateCodeRef.current.blur(); + }, [props.account.isLoading, props.session.autoAuthState, hasError]); + useEffect(() => { if (!inputValidateCodeRef.current || prevIsVisible || !props.isVisible || !canFocusInputOnScreenFocus()) { return; @@ -219,8 +237,6 @@ function BaseValidateCodeForm(props) { } }, [props.account.requiresTwoFactorAuth, props.credentials, props.preferredLocale, twoFactorAuthCode, validateCode]); - const hasError = Boolean(props.account) && !_.isEmpty(props.account.errors); - return ( <> {/* At this point, if we know the account requires 2FA we already successfully authenticated */} @@ -273,7 +289,9 @@ function BaseValidateCodeForm(props) { accessibilityRole="button" accessibilityLabel={props.translate('validateCodeForm.magicCodeNotReceived')} > - {props.translate('validateCodeForm.magicCodeNotReceived')} + + {hasError ? props.translate('validateCodeForm.requestNewCodeAfterErrorOccurred') : props.translate('validateCodeForm.magicCodeNotReceived')} + )} @@ -309,6 +327,7 @@ export default compose( account: {key: ONYXKEYS.ACCOUNT}, credentials: {key: ONYXKEYS.CREDENTIALS}, preferredLocale: {key: ONYXKEYS.NVP_PREFERRED_LOCALE}, + session: {key: ONYXKEYS.SESSION}, }), withToggleVisibilityView, withNetwork(), diff --git a/src/pages/tasks/NewTaskDescriptionPage.js b/src/pages/tasks/NewTaskDescriptionPage.js index b283549d93f0..287ec0396d92 100644 --- a/src/pages/tasks/NewTaskDescriptionPage.js +++ b/src/pages/tasks/NewTaskDescriptionPage.js @@ -63,7 +63,7 @@ function NewTaskDescriptionPage(props) {
onSubmit(values)} enabledWhenOffline > diff --git a/src/pages/tasks/NewTaskDetailsPage.js b/src/pages/tasks/NewTaskDetailsPage.js index 80744721ad00..ef2cc8cfb3f7 100644 --- a/src/pages/tasks/NewTaskDetailsPage.js +++ b/src/pages/tasks/NewTaskDetailsPage.js @@ -84,7 +84,7 @@ function NewTaskPage(props) { validate(values)} onSubmit={(values) => onSubmit(values)} enabledWhenOffline diff --git a/src/pages/tasks/NewTaskPage.js b/src/pages/tasks/NewTaskPage.js index 127592601aec..f1c04a82bb0a 100644 --- a/src/pages/tasks/NewTaskPage.js +++ b/src/pages/tasks/NewTaskPage.js @@ -144,7 +144,7 @@ function NewTaskPage(props) { shouldShowBackButton onBackButtonPress={() => Navigation.goBack(ROUTES.NEW_TASK_DETAILS)} /> - + validate(values)} onSubmit={(values) => onSubmit(values)} enabledWhenOffline diff --git a/src/pages/workspace/WorkspaceInitialPage.js b/src/pages/workspace/WorkspaceInitialPage.js index 73f659574e71..a5bb8493da51 100644 --- a/src/pages/workspace/WorkspaceInitialPage.js +++ b/src/pages/workspace/WorkspaceInitialPage.js @@ -1,6 +1,6 @@ import _ from 'underscore'; import lodashGet from 'lodash/get'; -import React, {useCallback, useState} from 'react'; +import React, {useCallback, useEffect, useState} from 'react'; import {View, ScrollView} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import PropTypes from 'prop-types'; @@ -82,6 +82,13 @@ function WorkspaceInitialPage(props) { Navigation.navigate(ROUTES.SETTINGS_WORKSPACES); }, [props.reports, policy]); + useEffect(() => { + if (!isCurrencyModalOpen || policy.outputCurrency !== CONST.CURRENCY.USD) { + return; + } + setIsCurrencyModalOpen(false); + }, [policy.outputCurrency, isCurrencyModalOpen]); + /** * Call update workspace currency and hide the modal */ @@ -194,7 +201,7 @@ function WorkspaceInitialPage(props) { pendingAction={policy.pendingAction} onClose={() => dismissError(policy.id)} errors={policy.errors} - errorRowStyles={[styles.ph6, styles.pv2]} + errorRowStyles={[styles.ph5, styles.pv2]} > diff --git a/src/pages/workspace/WorkspaceInvitePage.js b/src/pages/workspace/WorkspaceInvitePage.js index 990320037b6d..2fc0cfc4c5fe 100644 --- a/src/pages/workspace/WorkspaceInvitePage.js +++ b/src/pages/workspace/WorkspaceInvitePage.js @@ -13,7 +13,6 @@ import compose from '../../libs/compose'; import ONYXKEYS from '../../ONYXKEYS'; import * as Policy from '../../libs/actions/Policy'; import FormAlertWithSubmitButton from '../../components/FormAlertWithSubmitButton'; -import FormSubmit from '../../components/FormSubmit'; import OptionsSelector from '../../components/OptionsSelector'; import * as OptionsListUtils from '../../libs/OptionsListUtils'; import CONST from '../../CONST'; @@ -93,10 +92,7 @@ class WorkspaceInvitePage extends React.Component { } componentDidUpdate(prevProps) { - if (!_.isEqual(prevProps.personalDetails, this.props.personalDetails)) { - this.updateOptionsWithSearchTerm(this.props.searchTerm); - } - if (!_.isEqual(prevProps.policyMembers, this.props.policyMembers)) { + if (!_.isEqual(prevProps.personalDetails, this.props.personalDetails) || !_.isEqual(prevProps.policyMembers, this.props.policyMembers)) { this.updateOptionsWithSearchTerm(this.state.searchTerm); } @@ -176,10 +172,23 @@ class WorkspaceInvitePage extends React.Component { updateOptionsWithSearchTerm(searchTerm = '') { const {personalDetails, userToInvite} = OptionsListUtils.getMemberInviteOptions(this.props.personalDetails, this.props.betas, searchTerm, this.getExcludedUsers()); + + // Update selectedOptions with the latest personalDetails and policyMembers information + const detailsMap = {}; + _.forEach(personalDetails, (detail) => (detailsMap[detail.login] = detail)); + const selectedOptions = []; + _.forEach(this.state.selectedOptions, (option) => { + if (!_.has(detailsMap, option.login)) { + return; + } + selectedOptions.push(detailsMap[option.login]); + }); + this.setState({ searchTerm, userToInvite, personalDetails, + selectedOptions, }); } @@ -267,52 +276,47 @@ class WorkspaceInvitePage extends React.Component { shouldShow={_.isEmpty(this.props.policy)} onBackButtonPress={() => Navigation.goBack(ROUTES.SETTINGS_WORKSPACES)} > - - { - this.clearErrors(); - Navigation.goBack(ROUTES.getWorkspaceMembersRoute(this.props.route.params.policyID)); - }} + { + this.clearErrors(); + Navigation.goBack(ROUTES.getWorkspaceMembersRoute(this.props.route.params.policyID)); + }} + /> + + + + + - - - - - - - + ); diff --git a/src/pages/workspace/WorkspaceNewRoomPage.js b/src/pages/workspace/WorkspaceNewRoomPage.js index 361610266b42..c59b6687a809 100644 --- a/src/pages/workspace/WorkspaceNewRoomPage.js +++ b/src/pages/workspace/WorkspaceNewRoomPage.js @@ -1,11 +1,10 @@ -import React from 'react'; +import React, {useState, useCallback, useMemo, useEffect} from 'react'; import {View} from 'react-native'; import _ from 'underscore'; import {withOnyx} from 'react-native-onyx'; import PropTypes from 'prop-types'; import * as Report from '../../libs/actions/Report'; -import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize'; -import compose from '../../libs/compose'; +import useLocalize from '../../hooks/useLocalize'; import HeaderWithBackButton from '../../components/HeaderWithBackButton'; import Navigation from '../../libs/Navigation/Navigation'; import ScreenWrapper from '../../components/ScreenWrapper'; @@ -55,8 +54,6 @@ const propTypes = { /** A collection of objects for all policies which key policy member objects by accountIDs */ allPolicyMembers: PropTypes.objectOf(PropTypes.objectOf(policyMemberPropType)), - - ...withLocalizePropTypes, }; const defaultProps = { betas: [], @@ -65,151 +62,145 @@ const defaultProps = { allPolicyMembers: {}, }; -class WorkspaceNewRoomPage extends React.Component { - constructor(props) { - super(props); - - this.state = { - visibilityDescription: this.props.translate('newRoomPage.restrictedDescription'), - }; - - this.validate = this.validate.bind(this); - this.submit = this.submit.bind(this); - this.updateVisibilityDescription = this.updateVisibilityDescription.bind(this); - } +function WorkspaceNewRoomPage(props) { + const {translate} = useLocalize(); + const [visibility, setVisibility] = useState(CONST.REPORT.VISIBILITY.RESTRICTED); + const visibilityDescription = useMemo(() => translate(`newRoomPage.${visibility}Description`), [translate, visibility]); /** * @param {Object} values - form input values passed by the Form component */ - submit(values) { - const policyMembers = _.map(_.keys(this.props.allPolicyMembers[`${ONYXKEYS.COLLECTION.POLICY_MEMBERS}${values.policyID}`]), (accountID) => Number(accountID)); + const submit = (values) => { + const policyMembers = _.map(_.keys(props.allPolicyMembers[`${ONYXKEYS.COLLECTION.POLICY_MEMBERS}${values.policyID}`]), (accountID) => Number(accountID)); Report.addPolicyReport(values.policyID, values.roomName, values.visibility, policyMembers); - } - - /** - * @param {String} visibility - form input value passed by the Form component - */ - updateVisibilityDescription(visibility) { - const visibilityDescription = this.props.translate(`newRoomPage.${visibility}Description`); - if (visibilityDescription === this.state.visibilityDescription) { - return; - } - this.setState({visibilityDescription}); - } + }; /** * @param {Object} values - form input values passed by the Form component * @returns {Boolean} */ - validate(values) { - const errors = {}; - - if (!values.roomName || values.roomName === CONST.POLICY.ROOM_PREFIX) { - // We error if the user doesn't enter a room name or left blank - ErrorUtils.addErrorMessage(errors, 'roomName', 'newRoomPage.pleaseEnterRoomName'); - } else if (values.roomName !== CONST.POLICY.ROOM_PREFIX && !ValidationUtils.isValidRoomName(values.roomName)) { - // We error if the room name has invalid characters - ErrorUtils.addErrorMessage(errors, 'roomName', 'newRoomPage.roomNameInvalidError'); - } else if (ValidationUtils.isReservedRoomName(values.roomName)) { - // Certain names are reserved for default rooms and should not be used for policy rooms. - ErrorUtils.addErrorMessage(errors, 'roomName', ['newRoomPage.roomNameReservedError', {reservedName: values.roomName}]); - } else if (ValidationUtils.isExistingRoomName(values.roomName, this.props.reports, values.policyID)) { - // Certain names are reserved for default rooms and should not be used for policy rooms. - ErrorUtils.addErrorMessage(errors, 'roomName', 'newRoomPage.roomAlreadyExistsError'); - } - - if (!values.policyID) { - errors.policyID = 'newRoomPage.pleaseSelectWorkspace'; + const validate = useCallback( + (values) => { + const errors = {}; + + if (!values.roomName || values.roomName === CONST.POLICY.ROOM_PREFIX) { + // We error if the user doesn't enter a room name or left blank + ErrorUtils.addErrorMessage(errors, 'roomName', 'newRoomPage.pleaseEnterRoomName'); + } else if (values.roomName !== CONST.POLICY.ROOM_PREFIX && !ValidationUtils.isValidRoomName(values.roomName)) { + // We error if the room name has invalid characters + ErrorUtils.addErrorMessage(errors, 'roomName', 'newRoomPage.roomNameInvalidError'); + } else if (ValidationUtils.isReservedRoomName(values.roomName)) { + // Certain names are reserved for default rooms and should not be used for policy rooms. + ErrorUtils.addErrorMessage(errors, 'roomName', ['newRoomPage.roomNameReservedError', {reservedName: values.roomName}]); + } else if (ValidationUtils.isExistingRoomName(values.roomName, props.reports, values.policyID)) { + // Certain names are reserved for default rooms and should not be used for policy rooms. + ErrorUtils.addErrorMessage(errors, 'roomName', 'newRoomPage.roomAlreadyExistsError'); + } + + if (!values.policyID) { + errors.policyID = 'newRoomPage.pleaseSelectWorkspace'; + } + + return errors; + }, + [props.reports], + ); + + // Workspaces are policies with type === 'free' + const workspaceOptions = useMemo( + () => + _.map( + _.filter(props.policies, (policy) => policy && policy.type === CONST.POLICY.TYPE.FREE), + (policy) => ({label: policy.name, key: policy.id, value: policy.id}), + ), + [props.policies], + ); + + const visibilityOptions = useMemo( + () => + _.map( + _.filter(_.values(CONST.REPORT.VISIBILITY), (visibilityOption) => visibilityOption !== CONST.REPORT.VISIBILITY.PUBLIC_ANNOUNCE), + (visibilityOption) => ({ + label: translate(`newRoomPage.visibilityOptions.${visibilityOption}`), + value: visibilityOption, + description: translate(`newRoomPage.${visibilityOption}Description`), + }), + ), + [translate], + ); + + useEffect(() => { + if (Permissions.canUsePolicyRooms(props.betas)) { + return; } + Log.info('Not showing create Policy Room page since user is not on policy rooms beta'); + Navigation.dismissModal(); + }, [props.betas]); - return errors; + if (!Permissions.canUsePolicyRooms(props.betas)) { + return null; } - render() { - if (!Permissions.canUsePolicyRooms(this.props.betas)) { - Log.info('Not showing create Policy Room page since user is not on policy rooms beta'); - Navigation.dismissModal(); - return null; - } - - // Workspaces are policies with type === 'free' - const workspaceOptions = _.map( - _.filter(this.props.policies, (policy) => policy && policy.type === CONST.POLICY.TYPE.FREE), - (policy) => ({label: policy.name, key: policy.id, value: policy.id}), - ); - - const visibilityOptions = _.map( - _.filter(_.values(CONST.REPORT.VISIBILITY), (visibilityOption) => visibilityOption !== CONST.REPORT.VISIBILITY.PUBLIC_ANNOUNCE), - (visibilityOption) => ({ - label: this.props.translate(`newRoomPage.visibilityOptions.${visibilityOption}`), - value: visibilityOption, - description: this.props.translate(`newRoomPage.${visibilityOption}Description`), - }), - ); - - return ( - + + - - - - - - - - - - - - {this.state.visibilityDescription} - - - ); - } + + + + + + + + + + {visibilityDescription} + + + ); } WorkspaceNewRoomPage.propTypes = propTypes; WorkspaceNewRoomPage.defaultProps = defaultProps; - -export default compose( - withOnyx({ - betas: { - key: ONYXKEYS.BETAS, - }, - policies: { - key: ONYXKEYS.COLLECTION.POLICY, - }, - reports: { - key: ONYXKEYS.COLLECTION.REPORT, - }, - allPolicyMembers: { - key: ONYXKEYS.COLLECTION.POLICY_MEMBERS, - }, - }), - withLocalize, -)(WorkspaceNewRoomPage); +WorkspaceNewRoomPage.displayName = 'WorkspaceNewRoomPage'; + +export default withOnyx({ + betas: { + key: ONYXKEYS.BETAS, + }, + policies: { + key: ONYXKEYS.COLLECTION.POLICY, + }, + reports: { + key: ONYXKEYS.COLLECTION.REPORT, + }, + allPolicyMembers: { + key: ONYXKEYS.COLLECTION.POLICY_MEMBERS, + }, +})(WorkspaceNewRoomPage); diff --git a/src/pages/workspace/WorkspacesListPage.js b/src/pages/workspace/WorkspacesListPage.js index f88ced6fc9fb..6f11c70a6707 100755 --- a/src/pages/workspace/WorkspacesListPage.js +++ b/src/pages/workspace/WorkspacesListPage.js @@ -156,7 +156,7 @@ class WorkspacesListPage extends Component { diff --git a/src/stories/TextInput.stories.js b/src/stories/TextInput.stories.js index 64329ffed715..098828c65198 100644 --- a/src/stories/TextInput.stories.js +++ b/src/stories/TextInput.stories.js @@ -60,32 +60,6 @@ PlaceholderInput.args = { placeholder: 'My placeholder text', }; -const AutoGrowInput = Template.bind({}); -AutoGrowInput.args = { - label: 'Autogrow input', - name: 'AutoGrow', - placeholder: 'My placeholder text', - autoGrow: true, - textInputContainerStyles: [ - { - minWidth: 150, - }, - ], -}; - -const AutoGrowHeightInput = Template.bind({}); -AutoGrowHeightInput.args = { - label: 'Autogrowheight input', - name: 'AutoGrowHeight', - placeholder: 'My placeholder text', - autoGrowHeight: true, - textInputContainerStyles: [ - { - maxHeight: 115, - }, - ], -}; - const PrefixedInput = Template.bind({}); PrefixedInput.args = { label: 'Prefixed input', @@ -126,5 +100,50 @@ HintAndErrorInput.args = { hint: 'Type "Oops!" to see the error', }; +// To use autoGrow we need to control the TextInput's value +function AutoGrowSupportInput(args) { + const [value, setValue] = useState(args.value || ''); + React.useEffect(() => { + setValue(args.value || ''); + }, [args.value]); + + return ( + + ); +} + +const AutoGrowInput = AutoGrowSupportInput.bind({}); +AutoGrowInput.args = { + label: 'Autogrow input', + name: 'AutoGrow', + placeholder: 'My placeholder text', + autoGrow: true, + textInputContainerStyles: [ + { + minWidth: 150, + maxWidth: 500, + }, + ], + value: '', +}; + +const AutoGrowHeightInput = AutoGrowSupportInput.bind({}); +AutoGrowHeightInput.args = { + label: 'Autogrowheight input', + name: 'AutoGrowHeight', + placeholder: 'My placeholder text', + autoGrowHeight: true, + textInputContainerStyles: [ + { + maxHeight: 115, + }, + ], +}; + export default story; export {AutoFocus, DefaultInput, DefaultValueInput, ErrorInput, ForceActiveLabel, PlaceholderInput, AutoGrowInput, AutoGrowHeightInput, PrefixedInput, MaxLengthInput, HintAndErrorInput}; diff --git a/src/styles/styles.js b/src/styles/styles.js index 74066af5e20a..1d0770d5ceb8 100644 --- a/src/styles/styles.js +++ b/src/styles/styles.js @@ -551,6 +551,10 @@ const styles = { marginVertical: 1, }, + noBorderRadius: { + borderRadius: 0, + }, + noRightBorderRadius: { borderTopRightRadius: 0, borderBottomRightRadius: 0, @@ -2165,14 +2169,14 @@ const styles = { }, twoFactorAuthCodesBox: ({isExtraSmallScreenWidth, isSmallScreenWidth}) => { - let paddingHorizontal = styles.ph15; + let paddingHorizontal = styles.ph9; if (isSmallScreenWidth) { - paddingHorizontal = styles.ph10; + paddingHorizontal = styles.ph4; } if (isExtraSmallScreenWidth) { - paddingHorizontal = styles.ph4; + paddingHorizontal = styles.ph2; } return { @@ -2198,12 +2202,11 @@ const styles = { flexDirection: 'row', flexWrap: 'wrap', gap: 12, - height: 148, }, twoFactorAuthCode: { fontFamily: fontFamily.MONOSPACE, - width: 100, + width: 112, textAlign: 'center', }, @@ -2212,10 +2215,15 @@ const styles = { justifyContent: 'center', gap: 12, marginTop: 20, + flexWrap: 'wrap', }, twoFactorAuthCodesButton: { - minWidth: 100, + minWidth: 112, + }, + + twoFactorAuthCopyCodeButton: { + minWidth: 110, }, anonymousRoomFooter: { @@ -2838,10 +2846,6 @@ const styles = { errorDot: { marginRight: 12, }, - menuItemErrorPadding: { - paddingLeft: 44, - paddingRight: 20, - }, }, dotIndicatorMessage: { @@ -2997,8 +3001,7 @@ const styles = { keyboardShortcutModalContainer: { maxHeight: '100%', - flexShrink: 0, - flexGrow: 0, + flex: 0, flexBasis: 'auto', }, diff --git a/src/styles/utilities/spacing.js b/src/styles/utilities/spacing.js index e6823e43e921..0e4bb8a69907 100644 --- a/src/styles/utilities/spacing.js +++ b/src/styles/utilities/spacing.js @@ -330,6 +330,10 @@ export default { paddingHorizontal: 32, }, + ph9: { + paddingHorizontal: 36, + }, + ph10: { paddingHorizontal: 40, }, diff --git a/tests/actions/ReportTest.js b/tests/actions/ReportTest.js index e98a5249187f..5ebffff507b8 100644 --- a/tests/actions/ReportTest.js +++ b/tests/actions/ReportTest.js @@ -659,7 +659,7 @@ describe('actions/Report', () => { const resultAction = _.first(_.values(reportActions)); // Add a reaction to the comment - Report.toggleEmojiReaction(REPORT_ID, resultAction, EMOJI); + Report.toggleEmojiReaction(REPORT_ID, resultAction.reportActionID, EMOJI); return waitForPromisesToResolve(); }) .then(() => { @@ -668,7 +668,7 @@ describe('actions/Report', () => { // Now we toggle the reaction while the skin tone has changed. // As the emoji doesn't support skin tones, the emoji // should get removed instead of added again. - Report.toggleEmojiReaction(REPORT_ID, resultAction, EMOJI, 2); + Report.toggleEmojiReaction(REPORT_ID, resultAction.reportActionID, EMOJI, 2); return waitForPromisesToResolve(); }) .then(() => { diff --git a/tests/unit/GooglePlacesUtilsTest.js b/tests/unit/GooglePlacesUtilsTest.js index ef7a4491fec0..1bb27bdd9f2f 100644 --- a/tests/unit/GooglePlacesUtilsTest.js +++ b/tests/unit/GooglePlacesUtilsTest.js @@ -129,6 +129,12 @@ const addressComponents = [ types: ['postal_code'], }, ]; + +const autoCompleteTerms = [ + {offset: 0, value: 'Bangladesh Border Road'}, + {offset: 24, value: 'Bangladesh'}, +]; + describe('GooglePlacesUtilsTest', () => { describe('getAddressComponents', () => { it('should find address components by type', () => { @@ -189,4 +195,14 @@ describe('GooglePlacesUtilsTest', () => { expect(executionTime).toBeLessThan(5.0); }); }); + describe('getPlaceAutocompleteTerms', () => { + it('should find auto complete terms', () => { + expect(GooglePlacesUtils.getPlaceAutocompleteTerms(autoCompleteTerms)).toStrictEqual({ + country: 'Bangladesh', + state: 'Bangladesh Border Road', + city: '', + street: '', + }); + }); + }); }); diff --git a/tests/unit/MigrationTest.js b/tests/unit/MigrationTest.js index 6162bded793b..6ca3b5b2d516 100644 --- a/tests/unit/MigrationTest.js +++ b/tests/unit/MigrationTest.js @@ -845,5 +845,30 @@ describe('Migrations', () => { }, }); })); + + it('Should succeed in removing the personalDetails object if found in Onyx', () => + Onyx.multiSet({ + [`${DEPRECATED_ONYX_KEYS.PERSONAL_DETAILS}`]: { + 'test1@account.com': { + accountID: 100, + login: 'test1@account.com', + }, + 'test2@account.com': { + accountID: 101, + login: 'test2@account.com', + }, + }, + }) + .then(PersonalDetailsByAccountID) + .then(() => { + expect(LogSpy).toHaveBeenCalledWith('[Migrate Onyx] PersonalDetailsByAccountID migration: removing personalDetails'); + const connectionID = Onyx.connect({ + key: DEPRECATED_ONYX_KEYS.PERSONAL_DETAILS, + callback: (allPersonalDetails) => { + Onyx.disconnect(connectionID); + expect(allPersonalDetails).toBeNull(); + }, + }); + })); }); }); diff --git a/tests/unit/OptionsListUtilsTest.js b/tests/unit/OptionsListUtilsTest.js index 75c7927888eb..f12c367b0038 100644 --- a/tests/unit/OptionsListUtilsTest.js +++ b/tests/unit/OptionsListUtilsTest.js @@ -296,7 +296,7 @@ describe('OptionsListUtils', () => { it('getSearchOptions()', () => { // When we filter in the Search view without providing a searchValue - let results = OptionsListUtils.getSearchOptions(REPORTS, PERSONAL_DETAILS, ''); + let results = OptionsListUtils.getSearchOptions(REPORTS, PERSONAL_DETAILS, '', [CONST.BETAS.ALL]); // Then the 2 personalDetails that don't have reports should be returned expect(results.personalDetails.length).toBe(2); diff --git a/tests/utils/LHNTestUtils.js b/tests/utils/LHNTestUtils.js index 25df3fb01885..c00c69c3532a 100644 --- a/tests/utils/LHNTestUtils.js +++ b/tests/utils/LHNTestUtils.js @@ -168,37 +168,21 @@ function getAdvancedFakeReport(isArchived, isUserCreatedPolicyRoom, hasAddWorksp * @param {String} [currentReportID] */ function getDefaultRenderedSidebarLinks(currentReportID = '') { - // An ErrorBoundary needs to be added to the rendering so that any errors that happen while the component - // renders are logged to the console. Without an error boundary, Jest only reports the error like "The above error - // occurred in your component", except, there is no "above error". It's just swallowed up by Jest somewhere. - // With the ErrorBoundary, those errors are caught and logged to the console so you can find exactly which error - // might be causing a rendering issue when developing tests. - class ErrorBoundary extends React.Component { - // Error boundaries have to implement this method. It's for providing a fallback UI, but - // we don't need that for unit testing, so this is basically a no-op. - static getDerivedStateFromError(error) { - return {error}; - } + // A try-catch block needs to be added to the rendering so that any errors that happen while the component + // renders are caught and logged to the console. Without the try-catch block, Jest might only report the error + // as "The above error occurred in your component", without providing specific details. By using a try-catch block, + // any errors are caught and logged, allowing you to identify the exact error that might be causing a rendering issue + // when developing tests. - componentDidCatch(error, errorInfo) { - console.error(error, errorInfo); - } - - render() { - // eslint-disable-next-line react/prop-types - return this.props.children; - } + try { + // Wrap the SideBarLinks inside of LocaleContextProvider so that all the locale props + // are passed to the component. If this is not done, then all the locale props are missing + // and there are a lot of render warnings. It needs to be done like this because normally in + // our app (App.js) is when the react application is wrapped in the context providers + render(); + } catch (error) { + console.error(error); } - - // Wrap the SideBarLinks inside of LocaleContextProvider so that all the locale props - // are passed to the component. If this is not done, then all the locale props are missing - // and there are a lot of render warnings. It needs to be done like this because normally in - // our app (App.js) is when the react application is wrapped in the context providers - render( - - - , - ); } /**