diff --git a/android/app/build.gradle b/android/app/build.gradle index 1e54ef3d39be..2030a56bc45c 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 1001034400 - versionName "1.3.44-0" + versionCode 1001034401 + versionName "1.3.44-1" } splits { diff --git a/config/webpack/webpack.dev.js b/config/webpack/webpack.dev.js index 0303d9d57365..af302a0e663e 100644 --- a/config/webpack/webpack.dev.js +++ b/config/webpack/webpack.dev.js @@ -5,7 +5,7 @@ const {merge} = require('webpack-merge'); const {TimeAnalyticsPlugin} = require('time-analytics-webpack-plugin'); const getCommonConfig = require('./webpack.common'); -const BASE_PORT = 8080; +const BASE_PORT = 8082; /** * Configuration for the local dev server diff --git a/desktop/start.js b/desktop/start.js index 570f8fe13f07..d9ec59b71c83 100644 --- a/desktop/start.js +++ b/desktop/start.js @@ -3,7 +3,7 @@ const portfinder = require('portfinder'); const concurrently = require('concurrently'); require('dotenv').config(); -const basePort = 8080; +const basePort = 8082; portfinder .getPortPromise({ diff --git a/docs/_includes/CONST.html b/docs/_includes/CONST.html index 5ec5d296a336..4b87f87931d5 100644 --- a/docs/_includes/CONST.html +++ b/docs/_includes/CONST.html @@ -1,7 +1,7 @@ {% if jekyll.environment == "production" %} {% assign MAIN_SITE_URL = "https://new.expensify.com" %} {% else %} - {% assign MAIN_SITE_URL = "http://localhost:8080" %} + {% assign MAIN_SITE_URL = "http://localhost:8082" %} {% endif %} {% capture CONCIERGE_CHAT_URL %}{{MAIN_SITE_URL}}/concierge{% endcapture %} diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index d982d2fd3134..43e02c8e150e 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -32,7 +32,7 @@ CFBundleVersion - 1.3.44.0 + 1.3.44.1 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index ebbf274a9fd4..4123a6cfa96e 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -19,6 +19,6 @@ CFBundleSignature ???? CFBundleVersion - 1.3.44.0 + 1.3.44.1 diff --git a/package-lock.json b/package-lock.json index 39cc4882517f..ee5a03f93412 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.3.44-0", + "version": "1.3.44-1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.3.44-0", + "version": "1.3.44-1", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 2528838930e6..2337d609f7d6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.3.44-0", + "version": "1.3.44-1", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", @@ -9,11 +9,11 @@ "scripts": { "postinstall": "scripts/postInstall.sh", "clean": "npx react-native clean-project-auto", - "android": "scripts/set-pusher-suffix.sh && npx react-native run-android --port=8083", - "ios": "scripts/set-pusher-suffix.sh && npx react-native run-ios --port=8082", + "android": "scripts/set-pusher-suffix.sh && npx react-native run-android", + "ios": "scripts/set-pusher-suffix.sh && npx react-native run-ios", "pod-install": "cd ios && bundle exec pod install", - "ipad": "concurrently \"npx react-native run-ios --port=8082 --simulator=\"iPad Pro (12.9-inch) (4th generation)\"\"", - "ipad-sm": "concurrently \"npx react-native run-ios --port=8082 --simulator=\"iPad Pro (9.7-inch)\"\"", + "ipad": "concurrently \"npx react-native run-ios --simulator=\"iPad Pro (12.9-inch) (4th generation)\"\"", + "ipad-sm": "concurrently \"npx react-native run-ios --simulator=\"iPad Pro (9.7-inch)\"\"", "start": "npx react-native start", "web": "scripts/set-pusher-suffix.sh && concurrently npm:web-proxy npm:web-server", "web-proxy": "node web/proxy.js", diff --git a/src/components/Composer/index.js b/src/components/Composer/index.js index 23aa7a2902de..3d189fd197c7 100755 --- a/src/components/Composer/index.js +++ b/src/components/Composer/index.js @@ -10,6 +10,7 @@ import themeColors from '../../styles/themes/default'; import updateIsFullComposerAvailable from '../../libs/ComposerUtils/updateIsFullComposerAvailable'; import * as ComposerUtils from '../../libs/ComposerUtils'; import * as Browser from '../../libs/Browser'; +import * as StyleUtils from '../../styles/StyleUtils'; import withWindowDimensions, {windowDimensionsPropTypes} from '../withWindowDimensions'; import compose from '../../libs/compose'; import styles from '../../styles/styles'; @@ -482,6 +483,7 @@ class Composer extends React.Component { // We are hiding the scrollbar to prevent it from reducing the text input width, // so we can get the correct scroll height while calculating the number of lines. this.state.numberOfLines < this.props.maxLines ? styles.overflowHidden : {}, + StyleUtils.getComposeTextAreaPadding(this.props.numberOfLines), ]} /* eslint-disable-next-line react/jsx-props-no-spreading */ {...propsWithoutStyles} diff --git a/src/components/KeyboardSpacer/BaseKeyboardSpacer.js b/src/components/KeyboardSpacer/BaseKeyboardSpacer.js index 341d0ddf6f4c..2066f3492373 100644 --- a/src/components/KeyboardSpacer/BaseKeyboardSpacer.js +++ b/src/components/KeyboardSpacer/BaseKeyboardSpacer.js @@ -1,66 +1,53 @@ -import React, {PureComponent} from 'react'; +import React, {useState, useEffect, useCallback} from 'react'; import {Dimensions, Keyboard, View} from 'react-native'; import * as StyleUtils from '../../styles/StyleUtils'; import {propTypes, defaultProps} from './BaseKeyboardSpacerPropTypes'; -class BaseKeyboardSpacer extends PureComponent { - constructor(props) { - super(props); - this.state = { - keyboardSpace: 0, - }; - this.keyboardListeners = null; - this.updateKeyboardSpace = this.updateKeyboardSpace.bind(this); - this.resetKeyboardSpace = this.resetKeyboardSpace.bind(this); - } - - componentDidMount() { - const updateListener = this.props.keyboardShowMethod; - const resetListener = this.props.keyboardHideMethod; - this.keyboardListeners = [Keyboard.addListener(updateListener, this.updateKeyboardSpace), Keyboard.addListener(resetListener, this.resetKeyboardSpace)]; - } - - componentWillUnmount() { - this.keyboardListeners.forEach((listener) => listener.remove()); - } +function BaseKeyboardSpacer(props) { + const [keyboardSpace, setKeyboardSpace] = useState(0); /** * Update the height of Keyboard View. * * @param {Object} [event] - A Keyboard Event. */ - updateKeyboardSpace(event) { - if (!event.endCoordinates) { - return; - } - - const screenHeight = Dimensions.get('window').height; - const keyboardSpace = screenHeight - event.endCoordinates.screenY + this.props.topSpacing; - this.setState( - { - keyboardSpace, - }, - this.props.onToggle(true, keyboardSpace), - ); - } + const updateKeyboardSpace = useCallback( + (event) => { + if (!event.endCoordinates) { + return; + } + + const screenHeight = Dimensions.get('window').height; + const space = screenHeight - event.endCoordinates.screenY + props.topSpacing; + setKeyboardSpace(space); + props.onToggle(true, space); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); /** * Reset the height of Keyboard View. * * @param {Object} [event] - A Keyboard Event. */ - resetKeyboardSpace() { - this.setState( - { - keyboardSpace: 0, - }, - this.props.onToggle(false, 0), - ); - } + const resetKeyboardSpace = useCallback(() => { + setKeyboardSpace(0); + props.onToggle(false, 0); + }, [setKeyboardSpace, props]); + + useEffect(() => { + const updateListener = props.keyboardShowMethod; + const resetListener = props.keyboardHideMethod; + const keyboardListeners = [Keyboard.addListener(updateListener, updateKeyboardSpace), Keyboard.addListener(resetListener, resetKeyboardSpace)]; + + return () => { + keyboardListeners.forEach((listener) => listener.remove()); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); - render() { - return ; - } + return ; } BaseKeyboardSpacer.defaultProps = defaultProps; diff --git a/src/components/OptionsSelector/BaseOptionsSelector.js b/src/components/OptionsSelector/BaseOptionsSelector.js index 5941f6d17ac0..0d05e8401cce 100755 --- a/src/components/OptionsSelector/BaseOptionsSelector.js +++ b/src/components/OptionsSelector/BaseOptionsSelector.js @@ -2,7 +2,7 @@ import _ from 'underscore'; import lodashGet from 'lodash/get'; import React, {Component} from 'react'; import PropTypes from 'prop-types'; -import {View} from 'react-native'; +import {View, InteractionManager} from 'react-native'; import Button from '../Button'; import FixedFooter from '../FixedFooter'; import OptionsList from '../OptionsList'; @@ -125,6 +125,14 @@ class BaseOptionsSelector extends Component { } componentDidUpdate(prevProps) { + if (this.textInput && this.props.autoFocus && !prevProps.isFocused && this.props.isFocused) { + InteractionManager.runAfterInteractions(() => { + // If we automatically focus on a text input when mounting a component, + // let's automatically focus on it when the component updates as well (eg, when navigating back from a page) + this.textInput.focus(); + }); + } + if (_.isEqual(this.props.sections, prevProps.sections)) { return; } diff --git a/src/components/ReportActionItem/TaskView.js b/src/components/ReportActionItem/TaskView.js index 7eb77c47980a..99916534f986 100644 --- a/src/components/ReportActionItem/TaskView.js +++ b/src/components/ReportActionItem/TaskView.js @@ -109,7 +109,7 @@ function TaskView(props) { onPress={() => Navigation.navigate(ROUTES.getTaskReportDescriptionRoute(props.report.reportID))} shouldShowRightIcon={isOpen} disabled={disableState} - wrapperStyle={[styles.pv2]} + wrapperStyle={[styles.pv2, styles.taskDescriptionMenuItem]} shouldGreyOutWhenDisabled={false} numberOfLinesTitle={0} /> diff --git a/src/components/ReportWelcomeText.js b/src/components/ReportWelcomeText.js index 86c220400267..71392f46037e 100644 --- a/src/components/ReportWelcomeText.js +++ b/src/components/ReportWelcomeText.js @@ -114,9 +114,7 @@ function ReportWelcomeText(props) { ))} )} - {(moneyRequestOptions.includes(CONST.IOU.MONEY_REQUEST_TYPE.SEND) || moneyRequestOptions.includes(CONST.IOU.MONEY_REQUEST_TYPE.REQUEST)) && ( - {props.translate('reportActionsView.usePlusButton')} - )} + {moneyRequestOptions.includes(CONST.IOU.MONEY_REQUEST_TYPE.REQUEST) && {props.translate('reportActionsView.usePlusButton')}} ); diff --git a/src/components/TextInput/BaseTextInput.js b/src/components/TextInput/BaseTextInput.js index 2e96aeb7b2b9..68c09e3a7f82 100644 --- a/src/components/TextInput/BaseTextInput.js +++ b/src/components/TextInput/BaseTextInput.js @@ -395,7 +395,8 @@ function BaseTextInput(props) { setTextInputHeight(e.nativeEvent.layout.height); }} > - {props.value || props.placeholder} + {/* \u200B added to solve the issue of not expanding the text input enough when the value ends with '\n' (https://github.com/Expensify/App/issues/21271) */} + {props.value ? `${props.value}${props.value.endsWith('\n') ? '\u200B' : ''}` : props.placeholder} )} diff --git a/src/components/withWindowDimensions.js b/src/components/withWindowDimensions.js index 674a153f7e10..9ec9c5d4acbd 100644 --- a/src/components/withWindowDimensions.js +++ b/src/components/withWindowDimensions.js @@ -1,4 +1,4 @@ -import React, {forwardRef, createContext} from 'react'; +import React, {forwardRef, createContext, useState, useEffect} from 'react'; import PropTypes from 'prop-types'; import {Dimensions} from 'react-native'; import {SafeAreaInsetsContext} from 'react-native-safe-area-context'; @@ -32,77 +32,63 @@ const windowDimensionsProviderPropTypes = { children: PropTypes.node.isRequired, }; -class WindowDimensionsProvider extends React.Component { - constructor(props) { - super(props); +function WindowDimensionsProvider(props) { + const [windowDimension, setWindowDimension] = useState(() => { + const initialDimensions = Dimensions.get('window'); + return { + windowHeight: initialDimensions.height, + windowWidth: initialDimensions.width, + }; + }); - this.onDimensionChange = this.onDimensionChange.bind(this); + useEffect(() => { + const onDimensionChange = (newDimensions) => { + const {window} = newDimensions; - const initialDimensions = Dimensions.get('window'); + setWindowDimension({ + windowHeight: window.height, + windowWidth: window.width, + }); + }; - this.dimensionsEventListener = null; + const dimensionsEventListener = Dimensions.addEventListener('change', onDimensionChange); - this.state = { - windowHeight: initialDimensions.height, - windowWidth: initialDimensions.width, + return () => { + if (!dimensionsEventListener) { + return; + } + dimensionsEventListener.remove(); }; - } - - componentDidMount() { - this.dimensionsEventListener = Dimensions.addEventListener('change', this.onDimensionChange); - } - - componentWillUnmount() { - if (!this.dimensionsEventListener) { - return; - } - this.dimensionsEventListener.remove(); - } - - /** - * Stores the application window's width and height in a component state variable. - * Called each time the application's window dimensions or screen dimensions change. - * @link https://reactnative.dev/docs/dimensions - * @param {Object} newDimensions Dimension object containing updated window and screen dimensions - */ - onDimensionChange(newDimensions) { - const {window} = newDimensions; - - this.setState({ - windowHeight: window.height, - windowWidth: window.width, - }); - } - - render() { - return ( - - {(insets) => { - const isExtraSmallScreenWidth = this.state.windowWidth <= variables.extraSmallMobileResponsiveWidthBreakpoint; - const isSmallScreenWidth = this.state.windowWidth <= variables.mobileResponsiveWidthBreakpoint; - const isMediumScreenWidth = !isSmallScreenWidth && this.state.windowWidth <= variables.tabletResponsiveWidthBreakpoint; - const isLargeScreenWidth = !isSmallScreenWidth && !isMediumScreenWidth; - return ( - - {this.props.children} - - ); - }} - - ); - } + }, []); + + return ( + + {(insets) => { + const isExtraSmallScreenWidth = windowDimension.windowWidth <= variables.extraSmallMobileResponsiveWidthBreakpoint; + const isSmallScreenWidth = windowDimension.windowWidth <= variables.mobileResponsiveWidthBreakpoint; + const isMediumScreenWidth = !isSmallScreenWidth && windowDimension.windowWidth <= variables.tabletResponsiveWidthBreakpoint; + const isLargeScreenWidth = !isSmallScreenWidth && !isMediumScreenWidth; + return ( + + {props.children} + + ); + }} + + ); } WindowDimensionsProvider.propTypes = windowDimensionsProviderPropTypes; +WindowDimensionsProvider.displayName = 'WindowDimensionsProvider'; /** * @param {React.Component} WrappedComponent diff --git a/src/languages/en.js b/src/languages/en.js index 47cc8d209735..33fc3f3e74c7 100755 --- a/src/languages/en.js +++ b/src/languages/en.js @@ -298,7 +298,7 @@ export default { beginningOfChatHistoryPolicyExpenseChatPartThree: ' starts here! 🎉 This is the place to chat, request money and settle up.', chatWithAccountManager: 'Chat with your account manager here', sayHello: 'Say hello!', - usePlusButton: '\n\nYou can also use the + button below to send or request money!', + usePlusButton: '\n\nYou can also use the + button below to request money or assign a task!', }, reportAction: { asCopilot: 'as copilot for', diff --git a/src/languages/es.js b/src/languages/es.js index 5eea74099e4c..b481f89b4940 100644 --- a/src/languages/es.js +++ b/src/languages/es.js @@ -297,7 +297,7 @@ export default { beginningOfChatHistoryPolicyExpenseChatPartThree: ' empieza aquí! 🎉 Este es el lugar donde chatear, pedir dinero y pagar.', chatWithAccountManager: 'Chatea con tu gestor de cuenta aquí', sayHello: '¡Saluda!', - usePlusButton: '\n\n¡También puedes usar el botón + de abajo para enviar o pedir dinero!', + usePlusButton: '\n\n¡También puedes usar el botón + de abajo para pedir dinero o asignar una tarea!', }, reportAction: { asCopilot: 'como copiloto de', diff --git a/src/libs/OptionsListUtils.js b/src/libs/OptionsListUtils.js index 2acb1f51cbe7..56bbd326795d 100644 --- a/src/libs/OptionsListUtils.js +++ b/src/libs/OptionsListUtils.js @@ -801,7 +801,8 @@ function getOptions( (noOptions || noOptionsMatchExactly) && !isCurrentUser({login: searchValue}) && _.every(selectedOptions, (option) => option.login !== searchValue) && - ((Str.isValidEmail(searchValue) && !Str.isDomainEmail(searchValue) && !Str.endsWith(searchValue, CONST.SMS.DOMAIN)) || parsedPhoneNumber.possible) && + ((Str.isValidEmail(searchValue) && !Str.isDomainEmail(searchValue) && !Str.endsWith(searchValue, CONST.SMS.DOMAIN)) || + (parsedPhoneNumber.possible && Str.isValidPhone(LoginUtils.getPhoneNumberWithoutSpecialChars(parsedPhoneNumber.number.input)))) && !_.find(loginOptionsToExclude, (loginOptionToExclude) => loginOptionToExclude.login === addSMSDomainIfPhoneNumber(searchValue).toLowerCase()) && (searchValue !== CONST.EMAIL.CHRONOS || Permissions.canUseChronos(betas)) ) { diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js index c0ada6d34868..8dfd51f515e1 100644 --- a/src/libs/ReportUtils.js +++ b/src/libs/ReportUtils.js @@ -577,6 +577,16 @@ function isDM(report) { return !getChatType(report); } +/** + * Returns true if report has a single participant. + * + * @param {Object} report + * @returns {Boolean} + */ +function hasSingleParticipant(report) { + return report.participants && report.participants.length === 1; +} + /** * If the report is a thread and has a chat type set, it is a workspace chat. * @@ -2406,6 +2416,7 @@ function getMoneyRequestOptions(report, reportParticipants, betas) { const participants = _.filter(reportParticipants, (accountID) => currentUserPersonalDetails.accountID !== accountID); const hasExcludedIOUAccountIDs = lodashIntersection(reportParticipants, CONST.EXPENSIFY_ACCOUNT_IDS).length > 0; + const hasSingleParticipantInReport = participants.length === 1; const hasMultipleParticipants = participants.length > 1; if (hasExcludedIOUAccountIDs || (participants.length === 0 && !report.isOwnPolicyExpenseChat)) { @@ -2431,7 +2442,7 @@ function getMoneyRequestOptions(report, reportParticipants, betas) { ...(canRequestMoney(report) ? [CONST.IOU.MONEY_REQUEST_TYPE.REQUEST] : []), // Send money option should be visible only in DMs - ...(Permissions.canUseIOUSend(betas) && isChatReport(report) && !isPolicyExpenseChat(report) && participants.length === 1 ? [CONST.IOU.MONEY_REQUEST_TYPE.SEND] : []), + ...(Permissions.canUseIOUSend(betas) && isChatReport(report) && !isPolicyExpenseChat(report) && hasSingleParticipantInReport ? [CONST.IOU.MONEY_REQUEST_TYPE.SEND] : []), ]; } @@ -2727,7 +2738,9 @@ export { getOriginalReportID, canAccessReport, getReportOfflinePendingActionAndErrors, + isDM, getPolicy, shouldDisableSettings, shouldDisableRename, + hasSingleParticipant, }; diff --git a/src/libs/actions/PersistedRequests.js b/src/libs/actions/PersistedRequests.js index d893ee255287..c99ed2ace7fe 100644 --- a/src/libs/actions/PersistedRequests.js +++ b/src/libs/actions/PersistedRequests.js @@ -25,7 +25,15 @@ function save(requestsToPersist) { * @param {Object} requestToRemove */ function remove(requestToRemove) { - persistedRequests = _.reject(persistedRequests, (persistedRequest) => _.isEqual(persistedRequest, requestToRemove)); + /** + * We only remove the first matching request because the order of requests matters. + * If we were to remove all matching requests, we can end up with a final state that is different than what the user intended. + */ + const index = _.findIndex(persistedRequests, (persistedRequest) => _.isEqual(persistedRequest, requestToRemove)); + if (index !== -1) { + persistedRequests.splice(index, 1); + } + Onyx.set(ONYXKEYS.PERSISTED_REQUESTS, persistedRequests); } diff --git a/src/libs/actions/PersonalDetails.js b/src/libs/actions/PersonalDetails.js index afc6391f3003..9cf64e44038b 100644 --- a/src/libs/actions/PersonalDetails.js +++ b/src/libs/actions/PersonalDetails.js @@ -318,7 +318,7 @@ function updateSelectedTimezone(selectedTimezone) { ], }, ); - Navigation.navigate(ROUTES.SETTINGS_TIMEZONE); + Navigation.goBack(ROUTES.SETTINGS_TIMEZONE); } /** diff --git a/src/libs/actions/Task.js b/src/libs/actions/Task.js index b3dd76a38d78..32ffa759fa16 100644 --- a/src/libs/actions/Task.js +++ b/src/libs/actions/Task.js @@ -13,6 +13,7 @@ import * as UserUtils from '../UserUtils'; import * as ErrorUtils from '../ErrorUtils'; import * as ReportActionsUtils from '../ReportActionsUtils'; import * as Expensicons from '../../components/Icon/Expensicons'; +import * as LocalePhoneNumber from '../LocalePhoneNumber'; let currentUserEmail; let currentUserAccountID; @@ -597,10 +598,16 @@ function getAssignee(details) { * */ function getShareDestination(reportID, reports, personalDetails) { const report = lodashGet(reports, `report_${reportID}`, {}); + let subtitle = ''; + if (ReportUtils.isChatReport(report) && ReportUtils.isDM(report) && ReportUtils.hasSingleParticipant(report)) { + subtitle = LocalePhoneNumber.formatPhoneNumber(report.participants[0]); + } else { + subtitle = ReportUtils.getChatRoomSubtitle(report); + } return { icons: ReportUtils.getIcons(report, personalDetails, Expensicons.FallbackAvatar, ReportUtils.isIOUReport(report)), displayName: ReportUtils.getReportName(report), - subtitle: ReportUtils.getChatRoomSubtitle(report), + subtitle, }; } diff --git a/src/pages/ReportDetailsPage.js b/src/pages/ReportDetailsPage.js index b8daf12c2ca7..af54965b25ec 100644 --- a/src/pages/ReportDetailsPage.js +++ b/src/pages/ReportDetailsPage.js @@ -61,7 +61,7 @@ function ReportDetailsPage(props) { const policy = useMemo(() => props.policies[`${ONYXKEYS.COLLECTION.POLICY}${props.report.policyID}`], [props.policies, props.report.policyID]); const isPolicyAdmin = useMemo(() => PolicyUtils.isPolicyAdmin(policy), [policy]); const shouldDisableSettings = useMemo(() => ReportUtils.shouldDisableSettings(props.report), [props.report]); - const shouldUseFullTitle = shouldDisableSettings; + const shouldUseFullTitle = !shouldDisableSettings; 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/home/HeaderView.js b/src/pages/home/HeaderView.js index bc8cf0fed584..7622a0e73f18 100644 --- a/src/pages/home/HeaderView.js +++ b/src/pages/home/HeaderView.js @@ -91,8 +91,8 @@ function HeaderView(props) { 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 isConcierge = ReportUtils.hasSingleParticipant(props.report) && _.contains(participants, CONST.ACCOUNT_ID.CONCIERGE); + const isAutomatedExpensifyAccount = ReportUtils.hasSingleParticipant(props.report) && ReportUtils.hasAutomatedExpensifyAccountIDs(participants); const guideCalendarLink = lodashGet(props.account, 'guideCalendarLink'); // We hide the button when we are chatting with an automated Expensify account since it's not possible to contact diff --git a/src/pages/home/report/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose.js index 8155f09b2aac..8fc3eb1bb30d 100644 --- a/src/pages/home/report/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose.js @@ -49,6 +49,7 @@ import OfflineWithFeedback from '../../../components/OfflineWithFeedback'; import * as ComposerUtils from '../../../libs/ComposerUtils'; import * as Welcome from '../../../libs/actions/Welcome'; import Permissions from '../../../libs/Permissions'; +import containerComposeStyles from '../../../styles/containerComposeStyles'; import * as Task from '../../../libs/actions/Task'; import * as Browser from '../../../libs/Browser'; import * as IOU from '../../../libs/actions/IOU'; @@ -241,19 +242,13 @@ class ReportActionCompose extends React.Component { } componentDidMount() { - // This callback is used in the contextMenuActions to manage giving focus back to the compose input. - // TODO: we should clean up this convoluted code and instead move focus management to something like ReportFooter.js or another higher up component - ReportActionComposeFocusManager.onComposerFocus(() => { - if (!this.willBlurTextInputOnTapOutside || !this.props.isFocused) { - return; - } - - this.focus(false); - }); - this.unsubscribeNavigationBlur = this.props.navigation.addListener('blur', () => KeyDownListener.removeKeyDownPressListner(this.focusComposerOnKeyPress)); - this.unsubscribeNavigationFocus = this.props.navigation.addListener('focus', () => KeyDownListener.addKeyDownPressListner(this.focusComposerOnKeyPress)); + this.unsubscribeNavigationFocus = this.props.navigation.addListener('focus', () => { + KeyDownListener.addKeyDownPressListner(this.focusComposerOnKeyPress); + this.setUpComposeFocusManager(); + }); KeyDownListener.addKeyDownPressListner(this.focusComposerOnKeyPress); + this.setUpComposeFocusManager(); this.updateComment(this.comment); @@ -312,6 +307,18 @@ class ReportActionCompose extends React.Component { this.calculateMentionSuggestion(); } + setUpComposeFocusManager() { + // This callback is used in the contextMenuActions to manage giving focus back to the compose input. + // TODO: we should clean up this convoluted code and instead move focus management to something like ReportFooter.js or another higher up component + ReportActionComposeFocusManager.onComposerFocus(() => { + if (!this.willBlurTextInputOnTapOutside || !this.props.isFocused) { + return; + } + + this.focus(false); + }); + } + getDefaultSuggestionsValues() { return { suggestedEmojis: [], @@ -1110,7 +1117,7 @@ class ReportActionCompose extends React.Component { )} - + - + { diff --git a/src/pages/home/report/ReportActionsList.js b/src/pages/home/report/ReportActionsList.js index 85f0d406465d..90a1cdb464fe 100644 --- a/src/pages/home/report/ReportActionsList.js +++ b/src/pages/home/report/ReportActionsList.js @@ -169,7 +169,7 @@ function ReportActionsList(props) { ref={reportScrollManager.ref} data={props.sortedReportActions} renderItem={renderItem} - contentContainerStyle={[styles.chatContentScrollView, shouldShowReportRecipientLocalTime]} + contentContainerStyle={[styles.chatContentScrollView, shouldShowReportRecipientLocalTime ? styles.pt0 : {}]} keyExtractor={keyExtractor} initialRowHeight={32} initialNumToRender={calculateInitialNumToRender()} diff --git a/src/pages/settings/Payments/PaymentsPage/BasePaymentsPage.js b/src/pages/settings/Payments/PaymentsPage/BasePaymentsPage.js index 71ed4f044d4a..c1054dfad6c1 100644 --- a/src/pages/settings/Payments/PaymentsPage/BasePaymentsPage.js +++ b/src/pages/settings/Payments/PaymentsPage/BasePaymentsPage.js @@ -1,5 +1,5 @@ import React, {useCallback, useEffect, useState} from 'react'; -import {ActivityIndicator, View, InteractionManager, LayoutAnimation} from 'react-native'; +import {ActivityIndicator, View, InteractionManager} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; import PaymentMethodList from '../PaymentMethodList'; @@ -230,47 +230,27 @@ function BasePaymentsPage(props) { [setShouldShowDefaultDeleteMenu, setShowConfirmDeleteContent, resetSelectedPaymentMethodData], ); - const hidePasswordPrompt = useCallback( - (shouldClearSelectedData = true) => { - setShowPassword({ - shouldShowPasswordPrompt: false, - passwordButtonText: '', - }); - if (shouldClearSelectedData) { - resetSelectedPaymentMethodData(); - } - - // Due to iOS modal freeze issue, password modal freezes the app when closed. - // LayoutAnimation undoes the running animation. - LayoutAnimation.configureNext(LayoutAnimation.create(50, LayoutAnimation.Types.easeInEaseOut, LayoutAnimation.Properties.opacity)); - }, - [setShowPassword, resetSelectedPaymentMethodData], - ); + const makeDefaultPaymentMethod = useCallback(() => { + // Find the previous default payment method so we can revert if the MakeDefaultPaymentMethod command errors + const paymentMethods = PaymentUtils.formatPaymentMethods(props.bankAccountList, props.cardList); - const makeDefaultPaymentMethod = useCallback( - (password = '') => { - // Find the previous default payment method so we can revert if the MakeDefaultPaymentMethod command errors - const paymentMethods = PaymentUtils.formatPaymentMethods(props.bankAccountList, props.cardList); - - const previousPaymentMethod = _.find(paymentMethods, (method) => method.isDefault); - const currentPaymentMethod = _.find(paymentMethods, (method) => method.methodID === paymentMethod.methodID); - if (paymentMethod.selectedPaymentMethodType === CONST.PAYMENT_METHODS.BANK_ACCOUNT) { - PaymentMethods.makeDefaultPaymentMethod(password, paymentMethod.selectedPaymentMethod.bankAccountID, null, previousPaymentMethod, currentPaymentMethod); - } else if (paymentMethod.selectedPaymentMethodType === CONST.PAYMENT_METHODS.DEBIT_CARD) { - PaymentMethods.makeDefaultPaymentMethod(password, null, paymentMethod.selectedPaymentMethod.fundID, previousPaymentMethod, currentPaymentMethod); - } - resetSelectedPaymentMethodData(); - }, - [ - paymentMethod.methodID, - paymentMethod.selectedPaymentMethod.bankAccountID, - paymentMethod.selectedPaymentMethod.fundID, - paymentMethod.selectedPaymentMethodType, - props.bankAccountList, - props.cardList, - resetSelectedPaymentMethodData, - ], - ); + const previousPaymentMethod = _.find(paymentMethods, (method) => method.isDefault); + const currentPaymentMethod = _.find(paymentMethods, (method) => method.methodID === paymentMethod.methodID); + if (paymentMethod.selectedPaymentMethodType === CONST.PAYMENT_METHODS.BANK_ACCOUNT) { + PaymentMethods.makeDefaultPaymentMethod(paymentMethod.selectedPaymentMethod.bankAccountID, null, previousPaymentMethod, currentPaymentMethod); + } else if (paymentMethod.selectedPaymentMethodType === CONST.PAYMENT_METHODS.DEBIT_CARD) { + PaymentMethods.makeDefaultPaymentMethod(null, paymentMethod.selectedPaymentMethod.fundID, previousPaymentMethod, currentPaymentMethod); + } + resetSelectedPaymentMethodData(); + }, [ + paymentMethod.methodID, + paymentMethod.selectedPaymentMethod.bankAccountID, + paymentMethod.selectedPaymentMethod.fundID, + paymentMethod.selectedPaymentMethodType, + props.bankAccountList, + props.cardList, + resetSelectedPaymentMethodData, + ]); const deletePaymentMethod = useCallback(() => { if (paymentMethod.selectedPaymentMethodType === CONST.PAYMENT_METHODS.PAYPAL) { @@ -386,13 +366,10 @@ function BasePaymentsPage(props) { // Close corresponding selected payment method modals which are open if (shouldShowDefaultDeleteMenu) { hideDefaultDeleteMenu(); - } else if (showPassword.shouldShowPasswordPrompt) { - hidePasswordPrompt(); } } }, [ hideDefaultDeleteMenu, - hidePasswordPrompt, paymentMethod.methodID, paymentMethod.selectedPaymentMethodType, props.bankAccountList, @@ -509,7 +486,7 @@ function BasePaymentsPage(props) { deletePaymentMethod(); }} onCancel={hideDefaultDeleteMenu} - contentStyles={!isSmallScreenWidth ? [styles.sidebarPopover] : undefined} + contentStyles={!isSmallScreenWidth ? [styles.sidebarPopover, styles.willChangeTransform] : undefined} title={translate('paymentsPage.deleteAccount')} prompt={translate('paymentsPage.deleteConfirmation')} confirmText={translate('common.delete')} diff --git a/src/pages/settings/Profile/Contacts/ValidateCodeForm/BaseValidateCodeForm.js b/src/pages/settings/Profile/Contacts/ValidateCodeForm/BaseValidateCodeForm.js index 04a56a07eeb4..ea81413fcbb5 100644 --- a/src/pages/settings/Profile/Contacts/ValidateCodeForm/BaseValidateCodeForm.js +++ b/src/pages/settings/Profile/Contacts/ValidateCodeForm/BaseValidateCodeForm.js @@ -1,6 +1,7 @@ import React, {useCallback, useState, useEffect, useRef} from 'react'; import {View} from 'react-native'; import PropTypes from 'prop-types'; +import _ from 'underscore'; import {withOnyx} from 'react-native-onyx'; import lodashGet from 'lodash/get'; import MagicCodeInput from '../../../../../components/MagicCodeInput'; @@ -71,6 +72,7 @@ function BaseValidateCodeForm(props) { const [validateCode, setValidateCode] = useState(''); const loginData = props.loginList[props.contactMethod]; const inputValidateCodeRef = useRef(); + const validateLoginError = ErrorUtils.getEarliestErrorField(loginData, 'validateLogin'); useEffect(() => { if (!props.hasMagicCodeBeenSent) { @@ -134,6 +136,7 @@ function BaseValidateCodeForm(props) { value={validateCode} onChangeText={onTextInput} errorText={formError.validateCode ? props.translate(formError.validateCode) : ErrorUtils.getLatestErrorMessage(props.account)} + hasError={!_.isEmpty(validateLoginError)} onFulfill={validateAndSubmitForm} autoFocus shouldDelayFocus={shouldDelayFocus} @@ -168,7 +171,7 @@ function BaseValidateCodeForm(props) { User.clearContactMethodErrors(props.contactMethod, 'validateLogin')} > diff --git a/src/pages/tasks/TaskAssigneeSelectorModal.js b/src/pages/tasks/TaskAssigneeSelectorModal.js index 328d8837f724..6cad4cc12869 100644 --- a/src/pages/tasks/TaskAssigneeSelectorModal.js +++ b/src/pages/tasks/TaskAssigneeSelectorModal.js @@ -160,7 +160,7 @@ function TaskAssigneeSelectorModal(props) { // Clear out the state value, set the assignee and navigate back to the NewTaskPage setSearchValue(''); Task.setAssigneeValue(option.login, option.accountID, props.task.shareDestination, OptionsListUtils.isCurrentUser(option)); - return Navigation.goBack(); + return Navigation.goBack(ROUTES.NEW_TASK); } // Check to see if we're editing a task and if so, update the assignee diff --git a/src/pages/tasks/TaskShareDestinationSelectorModal.js b/src/pages/tasks/TaskShareDestinationSelectorModal.js index ce82aeeffe50..7a4250d8cbe9 100644 --- a/src/pages/tasks/TaskShareDestinationSelectorModal.js +++ b/src/pages/tasks/TaskShareDestinationSelectorModal.js @@ -130,7 +130,7 @@ function TaskShareDestinationSelectorModal(props) { // Clear out the state value, set the assignee and navigate back to the NewTaskPage setSearchValue(''); Task.setShareDestinationValue(option.reportID); - Navigation.goBack(); + Navigation.goBack(ROUTES.NEW_TASK); } }; diff --git a/src/pages/workspace/reimburse/WorkspaceReimburseSection.js b/src/pages/workspace/reimburse/WorkspaceReimburseSection.js index a53cc01d51d2..eb8305f23140 100644 --- a/src/pages/workspace/reimburse/WorkspaceReimburseSection.js +++ b/src/pages/workspace/reimburse/WorkspaceReimburseSection.js @@ -1,4 +1,4 @@ -import React from 'react'; +import React, {useState, useEffect} from 'react'; import PropTypes from 'prop-types'; import {ActivityIndicator, View} from 'react-native'; import lodashGet from 'lodash/get'; @@ -32,102 +32,91 @@ const propTypes = { translate: PropTypes.func.isRequired, }; -class WorkspaceReimburseSection extends React.Component { - constructor(props) { - super(props); - this.state = { - shouldShowLoadingSpinner: false, - }; +function WorkspaceReimburseSection(props) { + const [shouldShowLoadingSpinner, setShouldShowLoadingSpinner] = useState(false); + const achState = lodashGet(props.reimbursementAccount, 'achData.state', ''); + const hasVBA = achState === BankAccount.STATE.OPEN; + const reimburseReceiptsUrl = `reports?policyID=${props.policy.id}&from=all&type=expense&showStates=Archived&isAdvancedFilterMode=true`; + const debounceSetShouldShowLoadingSpinner = _.debounce(() => { + const isLoading = props.reimbursementAccount.isLoading || false; + if (isLoading !== shouldShowLoadingSpinner) { + setShouldShowLoadingSpinner(isLoading); + } + }, CONST.TIMING.SHOW_LOADING_SPINNER_DEBOUNCE_TIME); + useEffect(() => { + debounceSetShouldShowLoadingSpinner(); + }, [debounceSetShouldShowLoadingSpinner]); - this.debounceSetShouldShowLoadingSpinner = _.debounce(this.setShouldShowLoadingSpinner.bind(this), CONST.TIMING.SHOW_LOADING_SPINNER_DEBOUNCE_TIME); + if (props.network.isOffline) { + return ( +
+ + {`${props.translate('common.youAppearToBeOffline')} ${props.translate('common.thisFeatureRequiresInternet')}`} + +
+ ); } - componentDidUpdate() { - this.debounceSetShouldShowLoadingSpinner(); + // If the reimbursementAccount is loading but not enough time has passed to show a spinner, then render nothing. + if (props.reimbursementAccount.isLoading && !shouldShowLoadingSpinner) { + return null; } - setShouldShowLoadingSpinner() { - const shouldShowLoadingSpinner = this.props.reimbursementAccount.isLoading || false; - if (shouldShowLoadingSpinner !== this.state.shouldShowLoadingSpinner) { - this.setState({shouldShowLoadingSpinner}); - } + if (shouldShowLoadingSpinner) { + return ( + + + + ); } - render() { - const achState = lodashGet(this.props.reimbursementAccount, 'achData.state', ''); - const hasVBA = achState === BankAccount.STATE.OPEN; - const reimburseReceiptsUrl = `reports?policyID=${this.props.policy.id}&from=all&type=expense&showStates=Archived&isAdvancedFilterMode=true`; - - if (this.props.network.isOffline) { - return ( + return ( + <> + {hasVBA ? (
Link.openOldDotLink(reimburseReceiptsUrl), + icon: Expensicons.Bank, + shouldShowRightIcon: true, + iconRight: Expensicons.NewWindow, + wrapperStyle: [styles.cardMenuItem], + link: () => Link.buildOldDotURL(reimburseReceiptsUrl), + }, + ]} > - {`${this.props.translate('common.youAppearToBeOffline')} ${this.props.translate('common.thisFeatureRequiresInternet')}`} + {props.translate('workspace.reimburse.fastReimbursementsVBACopy')}
- ); - } - - // If the reimbursementAccount is loading but not enough time has passed to show a spinner, then render nothing. - if (this.props.reimbursementAccount.isLoading && !this.state.shouldShowLoadingSpinner) { - return null; - } - - if (this.state.shouldShowLoadingSpinner) { - return ( - - + + {props.translate('workspace.reimburse.unlockNoVBACopy')} + + - - ); - } - - return ( - <> - {hasVBA ? ( -
Link.openOldDotLink(reimburseReceiptsUrl), - icon: Expensicons.Bank, - shouldShowRightIcon: true, - iconRight: Expensicons.NewWindow, - wrapperStyle: [styles.cardMenuItem], - link: () => Link.buildOldDotURL(reimburseReceiptsUrl), - }, - ]} - > - - {this.props.translate('workspace.reimburse.fastReimbursementsVBACopy')} - -
- ) : ( -
- - {this.props.translate('workspace.reimburse.unlockNoVBACopy')} - - -
- )} - - ); - } + + )} + + ); } WorkspaceReimburseSection.propTypes = propTypes; +WorkspaceReimburseSection.displayName = 'WorkspaceReimburseSection'; export default WorkspaceReimburseSection; diff --git a/src/styles/StyleUtils.js b/src/styles/StyleUtils.js index c8449d16d176..707221a7c747 100644 --- a/src/styles/StyleUtils.js +++ b/src/styles/StyleUtils.js @@ -1222,6 +1222,22 @@ function getMentionTextColor(isOurMention) { return isOurMention ? themeColors.ourMentionText : themeColors.mentionText; } +/** + * Returns padding vertical based on number of lines + * @param {Number} numberOfLines + * @returns {Object} + */ +function getComposeTextAreaPadding(numberOfLines) { + let paddingValue = 5; + if (numberOfLines === 1) paddingValue = 9; + // In case numberOfLines = 3, there will be a Expand Icon appearing at the top left, so it has to be recalculated so that the textArea can be full height + if (numberOfLines === 3) paddingValue = 8; + return { + paddingTop: paddingValue, + paddingBottom: paddingValue, + }; +} + /** * Returns style object for the mobile on WEB * @param {Number} windowHeight @@ -1357,6 +1373,7 @@ export { getEmojiPickerListHeight, getMentionStyle, getMentionTextColor, + getComposeTextAreaPadding, getHeightOfMagicCodeInput, getOuterModalStyle, getWrappingStyle, diff --git a/src/styles/containerComposeStyles/index.js b/src/styles/containerComposeStyles/index.js new file mode 100644 index 000000000000..23a4d7ed7720 --- /dev/null +++ b/src/styles/containerComposeStyles/index.js @@ -0,0 +1,6 @@ +import styles from '../styles'; + +// We need to set paddingVertical = 0 on web to avoid displaying a normal pointer on some parts of compose box when not in focus +const containerComposeStyles = [styles.textInputComposeSpacing, {paddingVertical: 0}]; + +export default containerComposeStyles; diff --git a/src/styles/containerComposeStyles/index.native.js b/src/styles/containerComposeStyles/index.native.js new file mode 100644 index 000000000000..002331581108 --- /dev/null +++ b/src/styles/containerComposeStyles/index.native.js @@ -0,0 +1,5 @@ +import styles from '../styles'; + +const containerComposeStyles = [styles.textInputComposeSpacing]; + +export default containerComposeStyles; diff --git a/src/styles/styles.js b/src/styles/styles.js index 27d216e41a59..105b05e9475c 100644 --- a/src/styles/styles.js +++ b/src/styles/styles.js @@ -22,10 +22,14 @@ import pointerEventsAuto from './pointerEventsAuto'; import getPopOverVerticalOffset from './getPopOverVerticalOffset'; import overflowXHidden from './overflowXHidden'; import CONST from '../CONST'; +import * as Browser from '../libs/Browser'; import cursor from './utilities/cursor'; import userSelect from './utilities/userSelect'; import textUnderline from './utilities/textUnderline'; +// touchCallout is an iOS safari only property that controls the display of the callout information when you touch and hold a target +const touchCalloutNone = Browser.isMobileSafari() ? {WebkitTouchCallout: 'none'} : {}; + const picker = { backgroundColor: themeColors.transparent, color: themeColors.text, @@ -130,6 +134,7 @@ const webViewStyles = { borderColor: themeColors.border, borderRadius: variables.componentBorderRadiusNormal, borderWidth: 1, + ...touchCalloutNone, }, p: { @@ -3432,6 +3437,11 @@ const styles = { ...wordBreak.breakWord, }, + taskDescriptionMenuItem: { + maxWidth: '100%', + ...wordBreak.breakWord, + }, + taskTitleDescription: { fontFamily: fontFamily.EXP_NEUE, fontSize: variables.fontSizeLabel, @@ -3529,6 +3539,10 @@ const styles = { left: 0, right: 0, }), + + willChangeTransform: { + willChange: 'transform', + }, }; export default styles; diff --git a/tests/unit/OptionsListUtilsTest.js b/tests/unit/OptionsListUtilsTest.js index 86592e6f1aaf..76ac7990d133 100644 --- a/tests/unit/OptionsListUtilsTest.js +++ b/tests/unit/OptionsListUtilsTest.js @@ -522,6 +522,14 @@ describe('OptionsListUtils', () => { expect(results.userToInvite).not.toBe(null); expect(results.userToInvite.login).toBe('+18003243233'); + // When we use a search term for contact number that contains alphabet characters + results = OptionsListUtils.getNewChatOptions(REPORTS, PERSONAL_DETAILS, [], '998243aaaa'); + + // Then we shouldn't have any results or user to invite + expect(results.recentReports.length).toBe(0); + expect(results.personalDetails.length).toBe(0); + expect(results.userToInvite).toBe(null); + // Test Concierge's existence in new group options results = OptionsListUtils.getNewChatOptions(REPORTS_WITH_CONCIERGE, PERSONAL_DETAILS_WITH_CONCIERGE);