diff --git a/android/app/build.gradle b/android/app/build.gradle index b24050727c04..74f52a25b7fc 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 1001032805 - versionName "1.3.28-5" + versionCode 1001032903 + versionName "1.3.29-3" } splits { diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 7615c1afdbf2..36db5a1a7925 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.3.28 + 1.3.29 CFBundleSignature ???? CFBundleURLTypes @@ -32,7 +32,7 @@ CFBundleVersion - 1.3.28.5 + 1.3.29.3 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index a614839d7a4b..85da6b831671 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.3.28 + 1.3.29 CFBundleSignature ???? CFBundleVersion - 1.3.28.5 + 1.3.29.3 diff --git a/package-lock.json b/package-lock.json index 13838e053b58..91467cada5d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.3.28-5", + "version": "1.3.29-3", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.3.28-5", + "version": "1.3.29-3", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 9d202ca32b8d..d0b6acc87d94 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.3.28-5", + "version": "1.3.29-3", "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.", diff --git a/patches/@react-navigation+stack+6.3.16.patch b/patches/@react-navigation+stack+6.3.16.patch deleted file mode 100644 index 7bfa8af945f6..000000000000 --- a/patches/@react-navigation+stack+6.3.16.patch +++ /dev/null @@ -1,85 +0,0 @@ -diff --git a/node_modules/@react-navigation/stack/src/views/Stack/CardContainer.tsx b/node_modules/@react-navigation/stack/src/views/Stack/CardContainer.tsx -index 1e9ee0e..d85c7b4 100644 ---- a/node_modules/@react-navigation/stack/src/views/Stack/CardContainer.tsx -+++ b/node_modules/@react-navigation/stack/src/views/Stack/CardContainer.tsx -@@ -105,14 +105,14 @@ function CardContainer({ - const handleOpen = () => { - const { route } = scene.descriptor; - -- onTransitionEnd({ route }, false); -+ onTransitionEnd({ route }, false, scene.descriptor.navigation.getState()); - onOpenRoute({ route }); - }; - - const handleClose = () => { - const { route } = scene.descriptor; - -- onTransitionEnd({ route }, true); -+ onTransitionEnd({ route }, true, scene.descriptor.navigation.getState()); - onCloseRoute({ route }); - }; - -@@ -120,7 +120,7 @@ function CardContainer({ - const { route } = scene.descriptor; - - onPageChangeStart(); -- onGestureStart({ route }); -+ onGestureStart({ route }, scene.descriptor.navigation.getState()); - }; - - const handleGestureCanceled = () => { -diff --git a/node_modules/@react-navigation/stack/src/views/Stack/StackView.tsx b/node_modules/@react-navigation/stack/src/views/Stack/StackView.tsx -index 6bbce10..73594d3 100644 ---- a/node_modules/@react-navigation/stack/src/views/Stack/StackView.tsx -+++ b/node_modules/@react-navigation/stack/src/views/Stack/StackView.tsx -@@ -385,19 +385,47 @@ export default class StackView extends React.Component { - - private handleTransitionEnd = ( - { route }: { route: Route }, -- closing: boolean -- ) => -+ closing: boolean, -+ state: StackNavigationState -+ ) => { - this.props.navigation.emit({ - type: 'transitionEnd', - data: { closing }, - target: route.key, - }); -+ // Patch introduced to pass information about events to screens lower in the stack, so they could be safely frozen -+ if (state?.index > 1) { -+ this.props.navigation.emit({ -+ type: 'transitionEnd', -+ data: { closing: !closing }, -+ target: state.routes[state.index - 2].key, -+ }); -+ } -+ // We want the screen behind the closing screen to not be frozen -+ if (state?.index > 0) { -+ this.props.navigation.emit({ -+ type: 'transitionEnd', -+ data: { closing: false }, -+ target: state.routes[state.index - 1].key, -+ }); -+ } -+ } - -- private handleGestureStart = ({ route }: { route: Route }) => { -+ private handleGestureStart = ( -+ { route }: { route: Route }, -+ state: StackNavigationState -+ ) => { - this.props.navigation.emit({ - type: 'gestureStart', - target: route.key, - }); -+ // Patch introduced to pass information about events to screens lower in the stack, so they could be safely frozen -+ if (state?.index > 1) { -+ this.props.navigation.emit({ -+ type: 'gestureStart', -+ target: state.routes[state.index - 2].key, -+ }); -+ } - }; - - private handleGestureEnd = ({ route }: { route: Route }) => { diff --git a/src/components/AttachmentCarousel/index.js b/src/components/AttachmentCarousel/index.js index 77ab66400ae2..d4f7553d311c 100644 --- a/src/components/AttachmentCarousel/index.js +++ b/src/components/AttachmentCarousel/index.js @@ -176,6 +176,14 @@ class AttachmentCarousel extends React.Component { _.forEach(actions, (action) => htmlParser.write(_.get(action, ['message', 0, 'html']))); htmlParser.end(); + // Inverting the list for touchscreen devices that can swipe or have an animation when scrolling + // promotes the natural feeling of swiping left/right to go to the next/previous image + // We don't want to invert the list for desktop/web because this interferes with mouse + // wheel or trackpad scrolling (in cases like document preview where you can scroll vertically) + if (this.canUseTouchScreen) { + attachments.reverse(); + } + const page = _.findIndex(attachments, (a) => a.source === this.props.source); if (page === -1) { throw new Error('Attachment not found'); @@ -195,7 +203,12 @@ class AttachmentCarousel extends React.Component { * @param {Number} deltaSlide */ cycleThroughAttachments(deltaSlide) { - const nextIndex = this.state.page - deltaSlide; + let delta = deltaSlide; + if (this.canUseTouchScreen) { + delta = deltaSlide * -1; + } + + const nextIndex = this.state.page - delta; const nextItem = this.state.attachments[nextIndex]; if (!nextItem || !this.scrollRef.current) { @@ -262,8 +275,13 @@ class AttachmentCarousel extends React.Component { } render() { - const isForwardDisabled = this.state.page === 0; - const isBackDisabled = this.state.page === _.size(this.state.attachments) - 1; + let isForwardDisabled = this.state.page === 0; + let isBackDisabled = this.state.page === _.size(this.state.attachments) - 1; + + if (this.canUseTouchScreen) { + isForwardDisabled = isBackDisabled; + isBackDisabled = this.state.page === 0; + } return ( {({show}) => ( showContextMenuForReport(event, anchor, report.reportID, action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report))} + accessibilityRole="imagebutton" + accessibilityLabel={props.translate('accessibilityHints.viewAttachment')} > { - if (!option.login) { + if (!option.accountID) { return; } - Navigation.navigate(ROUTES.getProfileRoute(ReportUtils.getAccountIDForLogin(option.login))); + Navigation.navigate(ROUTES.getProfileRoute(option.accountID)); }; /** diff --git a/src/components/MultipleAvatars.js b/src/components/MultipleAvatars.js index 83211ee1dafc..46535a259210 100644 --- a/src/components/MultipleAvatars.js +++ b/src/components/MultipleAvatars.js @@ -78,7 +78,7 @@ function MultipleAvatars(props) { if (props.icons.length === 1 && !props.shouldStackHorizontally) { return ( ( ) : ( - + {/* View is necessary for tooltip to show for multiple avatars in LHN */} {props.icons.length === 2 ? ( - + e === null); + const hasErrorMessages = !_.isEmpty(errorMessages); const isOfflinePendingAction = props.network.isOffline && props.pendingAction; const isUpdateOrDeleteError = hasErrors && (props.pendingAction === 'delete' || props.pendingAction === 'update'); const isAddError = hasErrors && props.pendingAction === 'add'; @@ -111,11 +115,11 @@ function OfflineWithFeedback(props) { {children} )} - {props.shouldShowErrorMessages && hasErrors && ( + {props.shouldShowErrorMessages && hasErrorMessages && ( diff --git a/src/components/PinButton.js b/src/components/PinButton.js index 6292123faa39..800e0a828d1d 100644 --- a/src/components/PinButton.js +++ b/src/components/PinButton.js @@ -1,5 +1,4 @@ import React from 'react'; -import {Pressable} from 'react-native'; import styles from '../styles/styles'; import themeColors from '../styles/themes/default'; import Icon from './Icon'; @@ -9,6 +8,7 @@ import reportPropTypes from '../pages/reportPropTypes'; import * as Report from '../libs/actions/Report'; import * as Expensicons from './Icon/Expensicons'; import * as Session from '../libs/actions/Session'; +import PressableWithFeedback from './Pressable/PressableWithFeedback'; const propTypes = { /** Report to pin */ @@ -23,15 +23,18 @@ const defaultProps = { function PinButton(props) { return ( - Report.togglePinnedState(props.report.reportID, props.report.isPinned))} style={[styles.touchableButtonImage]} + accessibilityState={{checked: props.report.isPinned}} + accessibilityLabel={props.report.isPinned ? props.translate('common.unPin') : props.translate('common.pin')} + accessibilityRole="button" > - + ); } diff --git a/src/components/PopoverMenu/index.js b/src/components/PopoverMenu/index.js index 4de4a2341283..1cafa9e12664 100644 --- a/src/components/PopoverMenu/index.js +++ b/src/components/PopoverMenu/index.js @@ -41,6 +41,7 @@ const defaultProps = { function PopoverMenu(props) { const {isSmallScreenWidth} = useWindowDimensions(); const [selectedItemIndex, setSelectedItemIndex] = useState(null); + const [focusedIndex, setFocusedIndex] = useArrowKeyFocusManager({initialFocusedIndex: -1, maxIndex: props.menuItems.length - 1, isActive: props.isVisible}); const selectItem = (index) => { const selectedItem = props.menuItems[index]; @@ -48,7 +49,6 @@ function PopoverMenu(props) { setSelectedItemIndex(index); }; - const [focusedIndex, setFocusedIndex] = useArrowKeyFocusManager({initialFocusedIndex: -1, maxIndex: props.menuItems.length - 1}); useKeyboardShortcut( CONST.KEYBOARD_SHORTCUTS.ENTER, () => { diff --git a/src/components/Pressable/PressableWithFeedback.js b/src/components/Pressable/PressableWithFeedback.js index 94f10a380bb9..cb86fd3d9e98 100644 --- a/src/components/Pressable/PressableWithFeedback.js +++ b/src/components/Pressable/PressableWithFeedback.js @@ -11,7 +11,7 @@ import * as StyleUtils from '../../styles/StyleUtils'; const omittedProps = ['style', 'pressStyle', 'hoverStyle', 'focusStyle', 'wrapperStyle']; const PressableWithFeedbackPropTypes = { - ..._.omit(GenericPressablePropTypes.pressablePropTypes, omittedProps), + ...GenericPressablePropTypes.pressablePropTypes, /** * Determines what opacity value should be applied to the underlaying view when Pressable is pressed. * To disable dimming, pass 1 as pressDimmingValue @@ -31,7 +31,7 @@ const PressableWithFeedbackPropTypes = { }; const PressableWithFeedbackDefaultProps = { - ..._.omit(GenericPressablePropTypes.defaultProps, omittedProps), + ...GenericPressablePropTypes.defaultProps, pressDimmingValue: variables.pressDimValue, hoverDimmingValue: variables.hoverDimValue, nativeID: '', diff --git a/src/components/PressableWithoutFocus.js b/src/components/Pressable/PressableWithoutFocus.js similarity index 70% rename from src/components/PressableWithoutFocus.js rename to src/components/Pressable/PressableWithoutFocus.js index 5b441f4ee9f4..39f0cd34abec 100644 --- a/src/components/PressableWithoutFocus.js +++ b/src/components/Pressable/PressableWithoutFocus.js @@ -1,6 +1,8 @@ import React from 'react'; -import {Pressable} from 'react-native'; +import _ from 'underscore'; import PropTypes from 'prop-types'; +import GenericPressable from './GenericPressable'; +import genericPressablePropTypes from './GenericPressable/PropTypes'; const propTypes = { /** Element that should be clickable */ @@ -14,11 +16,14 @@ const propTypes = { /** Styles that should be passed to touchable container */ // eslint-disable-next-line react/forbid-prop-types - styles: PropTypes.arrayOf(PropTypes.object), + style: PropTypes.arrayOf(PropTypes.object), + + /** Proptypes of pressable component used for implementation */ + ...genericPressablePropTypes.pressablePropTypes, }; const defaultProps = { - styles: [], + style: [], onLongPress: undefined, }; @@ -41,15 +46,18 @@ class PressableWithoutFocus extends React.Component { } render() { + const restProps = _.omit(this.props, ['children', 'onPress', 'onLongPress', 'style']); return ( - (this.pressableRef = el)} - style={this.props.styles} + style={this.props.style} + // eslint-disable-next-line react/jsx-props-no-spreading + {...restProps} > {this.props.children} - + ); } } diff --git a/src/components/QRCode/index.js b/src/components/QRCode/index.js index 10a99fca5d99..f27cf28066ef 100644 --- a/src/components/QRCode/index.js +++ b/src/components/QRCode/index.js @@ -34,11 +34,6 @@ const propTypes = { * The QRCode background color */ backgroundColor: PropTypes.string, - - /** - * The QRCode logo background color - */ - logoBackgroundColor: PropTypes.string, /** * Function to retrieve the internal component ref and be able to call it's * methods @@ -50,7 +45,6 @@ const defaultProps = { logo: undefined, size: 120, color: defaultTheme.text, - logoBackgroundColor: defaultTheme.icon, backgroundColor: defaultTheme.highlightBG, getRef: undefined, logoRatio: CONST.QR.DEFAULT_LOGO_SIZE_RATIO, @@ -64,7 +58,7 @@ function QRCode(props) { value={props.url} size={props.size} logo={props.logo} - logoBackgroundColor={props.logoBackgroundColor} + logoBackgroundColor={props.backgroundColor} logoSize={props.size * props.logoRatio} logoMargin={props.size * props.logoMarginRatio} logoBorderRadius={props.size} diff --git a/src/components/ReportActionItem/ReportPreview.js b/src/components/ReportActionItem/ReportPreview.js index 585e91c3f559..7fa9ebcd6745 100644 --- a/src/components/ReportActionItem/ReportPreview.js +++ b/src/components/ReportActionItem/ReportPreview.js @@ -1,5 +1,5 @@ import React from 'react'; -import {View, Pressable} from 'react-native'; +import {View} from 'react-native'; import PropTypes from 'prop-types'; import {withOnyx} from 'react-native-onyx'; import lodashGet from 'lodash/get'; @@ -25,6 +25,7 @@ import themeColors from '../../styles/themes/default'; import getButtonState from '../../libs/getButtonState'; import * as IOU from '../../libs/actions/IOU'; import refPropTypes from '../refPropTypes'; +import PressableWithoutFeedback from '../Pressable/PressableWithoutFeedback'; const propTypes = { /** All the data of the action */ @@ -103,7 +104,7 @@ function ReportPreview(props) { return ( {_.map(props.action.message, (message, index) => ( - { Navigation.navigate(ROUTES.getReportRoute(props.iouReportID)); @@ -112,7 +113,8 @@ function ReportPreview(props) { onPressOut={() => ControlSelection.unblock()} onLongPress={(event) => showContextMenuForReport(event, props.contextMenuAnchor, props.chatReportID, props.action, props.checkIfContextMenuActive)} style={[styles.flexRow, styles.justifyContentBetween]} - focusable + accessibilityRole="button" + accessibilityLabel={props.translate('iou.viewDetails')} > {props.iouReport.hasOutstandingIOU ? ( @@ -140,7 +142,7 @@ function ReportPreview(props) { src={Expensicons.ArrowRight} fill={StyleUtils.getIconFillColor(getButtonState(props.isHovered))} /> - + ))} {isCurrentUserManager && !ReportUtils.isSettled(props.iouReport.reportID) && ( @${taskAssignee} ${taskTitle}` : `${taskTitle}`; return ( - Navigation.navigate(ROUTES.getReportRoute(props.taskReportID))} style={[styles.flexRow, styles.justifyContentBetween]} + accessibilityRole="button" + accessibilityLabel={props.translate('newTaskPage.task')} > - + ); } diff --git a/src/components/ReportWelcomeText.js b/src/components/ReportWelcomeText.js index c000d88f4c8c..7c1b0252bee4 100644 --- a/src/components/ReportWelcomeText.js +++ b/src/components/ReportWelcomeText.js @@ -19,7 +19,7 @@ import CONST from '../CONST'; const personalDetailsPropTypes = PropTypes.shape({ /** The login of the person (either email or phone number) */ - login: PropTypes.string.isRequired, + login: PropTypes.string, /** The URL of the person's avatar (there should already be a default avatar if the person doesn't have their own avatar uploaded yet, except for anon users) */ diff --git a/src/components/TaskHeader.js b/src/components/TaskHeader.js index 001ed2b10bdd..c9852a8cfcbf 100644 --- a/src/components/TaskHeader.js +++ b/src/components/TaskHeader.js @@ -2,6 +2,7 @@ import React, {useEffect} from 'react'; import {View} from 'react-native'; import PropTypes from 'prop-types'; import lodashGet from 'lodash/get'; +import {withOnyx} from 'react-native-onyx'; import reportPropTypes from '../pages/reportPropTypes'; import withLocalize, {withLocalizePropTypes} from './withLocalize'; import * as ReportUtils from '../libs/ReportUtils'; @@ -22,6 +23,7 @@ import Button from './Button'; import * as TaskUtils from '../libs/actions/Task'; import * as UserUtils from '../libs/UserUtils'; import PressableWithFeedback from './Pressable/PressableWithFeedback'; +import ONYXKEYS from '../ONYXKEYS'; const propTypes = { /** The report currently being looked at */ @@ -30,13 +32,25 @@ const propTypes = { /** Personal details so we can get the ones for the report participants */ personalDetails: PropTypes.objectOf(participantPropTypes).isRequired, + /** Current user session */ + session: PropTypes.shape({ + accountID: PropTypes.number, + }), + ...withLocalizePropTypes, }; +const defaultProps = { + session: { + accountID: 0, + }, +}; + function TaskHeader(props) { const title = ReportUtils.getReportName(props.report); - const assigneeName = ReportUtils.getDisplayNameForParticipant(props.report.managerID); - const assigneeAvatar = UserUtils.getAvatar(lodashGet(props.personalDetails, [props.report.managerID, 'avatar']), props.report.managerID); + const assigneeAccountID = TaskUtils.getTaskAssigneeAccountID(props.report); + const assigneeName = ReportUtils.getDisplayNameForParticipant(assigneeAccountID); + const assigneeAvatar = UserUtils.getAvatar(lodashGet(props.personalDetails, [assigneeAccountID, 'avatar']), assigneeAccountID); const isOpen = props.report.stateNum === CONST.REPORT.STATE_NUM.OPEN && props.report.statusNum === CONST.REPORT.STATUS.OPEN; const isCompleted = ReportUtils.isTaskCompleted(props.report); @@ -59,7 +73,7 @@ function TaskHeader(props) { > - {props.report.managerID && props.report.managerID > 0 && ( + {assigneeAccountID && assigneeAccountID > 0 && ( <> TaskUtils.completeTask(props.report.reportID, title)} @@ -123,6 +137,15 @@ function TaskHeader(props) { } TaskHeader.propTypes = propTypes; +TaskHeader.defaultProps = defaultProps; TaskHeader.displayName = 'TaskHeader'; -export default compose(withWindowDimensions, withLocalize)(TaskHeader); +export default compose( + withWindowDimensions, + withLocalize, + withOnyx({ + session: { + key: ONYXKEYS.SESSION, + }, + }), +)(TaskHeader); diff --git a/src/components/UserDetailsTooltip/index.js b/src/components/UserDetailsTooltip/index.js index 5dd2fa1c5785..786531801837 100644 --- a/src/components/UserDetailsTooltip/index.js +++ b/src/components/UserDetailsTooltip/index.js @@ -19,7 +19,7 @@ function UserDetailsTooltip(props) { @@ -28,11 +28,11 @@ function UserDetailsTooltip(props) { - {String(userDetails.login).trim() && !_.isEqual(userDetails.login, userDetails.displayName) ? Str.removeSMSDomain(userDetails.login) : ''} + {String(userDetails.login || '').trim() && !_.isEqual(userDetails.login, userDetails.displayName) ? Str.removeSMSDomain(userDetails.login) : ''} ), - [userDetails.avatar, userDetails.displayName, userDetails.login], + [userDetails.avatar, userDetails.displayName, userDetails.login, userDetails.accountID], ); if (!userDetails.displayName && !userDetails.login) { diff --git a/src/components/withCurrentReportId.js b/src/components/withCurrentReportId.js index d05ac2f1bce8..4611187ed404 100644 --- a/src/components/withCurrentReportId.js +++ b/src/components/withCurrentReportId.js @@ -1,4 +1,4 @@ -import React, {createContext, forwardRef} from 'react'; +import React, {createContext, forwardRef, useCallback, useState, useMemo} from 'react'; import PropTypes from 'prop-types'; import getComponentDisplayName from '../libs/getComponentDisplayName'; @@ -7,52 +7,55 @@ import Navigation from '../libs/Navigation/Navigation'; const CurrentReportIdContext = createContext(null); const withCurrentReportIdPropTypes = { - /** Actual content wrapped by this component */ - children: PropTypes.node.isRequired, -}; + /** Function to update the state */ + updateCurrentReportId: PropTypes.func.isRequired, -class CurrentReportIdContextProvider extends React.Component { - constructor(props) { - super(props); + /** The top most report id */ + currentReportId: PropTypes.string, +}; - this.state = { - currentReportId: '', - }; - } +function CurrentReportIdContextProvider(props) { + const [currentReportId, setCurrentReportId] = useState(''); /** - * The context this component exposes to child components - * @returns {Object} currentReportId to share between central pane and LHN + * This function is used to update the currentReportId + * @param {Object} state root navigation state */ - getContextValue() { - return { - updateCurrentReportId: this.updateCurrentReportId.bind(this), - currentReportId: this.state.currentReportId, - }; - } + const updateCurrentReportId = useCallback( + (state) => { + setCurrentReportId(Navigation.getTopmostReportId(state)); + }, + [setCurrentReportId], + ); /** - * @param {Object} state - * @returns {String} + * The context this component exposes to child components + * @returns {Object} currentReportId to share between central pane and LHN */ - updateCurrentReportId(state) { - return this.setState({currentReportId: Navigation.getTopmostReportId(state)}); - } + const contextValue = useMemo( + () => ({ + updateCurrentReportId, + currentReportId, + }), + [updateCurrentReportId, currentReportId], + ); - render() { - return {this.props.children}; - } + return {props.children}; } -CurrentReportIdContextProvider.propTypes = withCurrentReportIdPropTypes; +CurrentReportIdContextProvider.displayName = 'CurrentReportIdContextProvider'; +CurrentReportIdContextProvider.propTypes = { + /** Actual content wrapped by this component */ + children: PropTypes.node.isRequired, +}; export default function withCurrentReportId(WrappedComponent) { const WithCurrentReportId = forwardRef((props, ref) => ( - {(translateUtils) => ( + {(currentReportIdUtils) => ( {}, initialFocusedIndex = 0, disabledIndexes = [], shouldExcludeTextAreaNodes = true}) { +export default function useArrowKeyFocusManager({ + maxIndex, + onFocusedIndexChange = () => {}, + initialFocusedIndex = 0, + disabledIndexes = EMPTY_ARRAY, + shouldExcludeTextAreaNodes = true, + isActive, +}) { const [focusedIndex, setFocusedIndex] = useState(initialFocusedIndex); + const arrowConfig = useMemo( + () => ({ + excludedNodes: shouldExcludeTextAreaNodes ? ['TEXTAREA'] : [], + isActive, + }), + [isActive, shouldExcludeTextAreaNodes], + ); + useEffect(() => onFocusedIndexChange(focusedIndex), [focusedIndex, onFocusedIndexChange]); - useKeyboardShortcut( - CONST.KEYBOARD_SHORTCUTS.ARROW_UP, - () => { - if (maxIndex < 0) { - return; - } + const arrowUpCallback = useCallback(() => { + if (maxIndex < 0) { + return; + } - const currentFocusedIndex = focusedIndex > 0 ? focusedIndex - 1 : maxIndex; + setFocusedIndex((actualIndex) => { + const currentFocusedIndex = actualIndex > 0 ? actualIndex - 1 : maxIndex; let newFocusedIndex = currentFocusedIndex; while (disabledIndexes.includes(newFocusedIndex)) { newFocusedIndex = newFocusedIndex > 0 ? newFocusedIndex - 1 : maxIndex; if (newFocusedIndex === currentFocusedIndex) { // all indexes are disabled - return; // no-op + return actualIndex; // no-op } } + return newFocusedIndex; + }); + }, [disabledIndexes, maxIndex]); + useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ARROW_UP, arrowUpCallback, arrowConfig); - setFocusedIndex(newFocusedIndex); - }, - { - excludedNodes: shouldExcludeTextAreaNodes ? ['TEXTAREA'] : [], - }, - ); - - useKeyboardShortcut( - CONST.KEYBOARD_SHORTCUTS.ARROW_DOWN, - () => { - if (maxIndex < 0) { - return; - } + const arrowDownCallback = useCallback(() => { + if (maxIndex < 0) { + return; + } - const currentFocusedIndex = focusedIndex < maxIndex ? focusedIndex + 1 : 0; + setFocusedIndex((actualIndex) => { + const currentFocusedIndex = actualIndex < maxIndex ? actualIndex + 1 : 0; let newFocusedIndex = currentFocusedIndex; while (disabledIndexes.includes(newFocusedIndex)) { newFocusedIndex = newFocusedIndex < maxIndex ? newFocusedIndex + 1 : 0; if (newFocusedIndex === currentFocusedIndex) { // all indexes are disabled - return; // no-op + return actualIndex; } } - setFocusedIndex(newFocusedIndex); - }, - { - excludedNodes: shouldExcludeTextAreaNodes ? ['TEXTAREA'] : [], - }, - ); + return newFocusedIndex; + }); + }, [disabledIndexes, maxIndex]); + useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ARROW_DOWN, arrowDownCallback, arrowConfig); // Note: you don't need to manually manage focusedIndex in the parent. setFocusedIndex is only exposed in case you want to reset focusedIndex or focus a specific item return [focusedIndex, setFocusedIndex]; diff --git a/src/hooks/useKeyboardShortcut.js b/src/hooks/useKeyboardShortcut.js index 434daeda1921..9b7244176864 100644 --- a/src/hooks/useKeyboardShortcut.js +++ b/src/hooks/useKeyboardShortcut.js @@ -1,20 +1,25 @@ -import {useEffect, useRef, useCallback} from 'react'; +import {useEffect} from 'react'; import KeyboardShortcut from '../libs/KeyboardShortcut'; +// Creating a default array this way because objects ({}) and arrays ([]) are not stable types. +// The "excludedNodes" array needs to be stable to prevent the "useEffect" hook from being recreated unnecessarily. +// Freezing the array ensures that it cannot be unintentionally modified. +const EMPTY_ARRAY = Object.freeze([]); + /** * Register a keyboard shortcut handler. + * Recommendation: To ensure stability, wrap the `callback` function with the useCallback hook before using it with this hook. * * @param {Object} shortcut * @param {Function} callback * @param {Object} [config] */ export default function useKeyboardShortcut(shortcut, callback, config = {}) { - const {captureOnInputs = true, shouldBubble = false, priority = 0, shouldPreventDefault = true, excludedNodes = [], isActive = true} = config; + const {captureOnInputs = true, shouldBubble = false, priority = 0, shouldPreventDefault = true, excludedNodes = EMPTY_ARRAY, isActive = true} = config; - const subscription = useRef(null); - const subscribe = useCallback( - () => - KeyboardShortcut.subscribe( + useEffect(() => { + if (isActive) { + return KeyboardShortcut.subscribe( shortcut.shortcutKey, callback, shortcut.descriptionKey, @@ -24,14 +29,8 @@ export default function useKeyboardShortcut(shortcut, callback, config = {}) { priority, shouldPreventDefault, excludedNodes, - ), - [callback, captureOnInputs, excludedNodes, priority, shortcut.descriptionKey, shortcut.modifiers, shortcut.shortcutKey, shouldBubble, shouldPreventDefault], - ); - - useEffect(() => { - const unsubscribe = subscription.current || (() => {}); - unsubscribe(); - subscription.current = isActive ? subscribe() : null; - return isActive ? subscription.current : () => {}; - }, [isActive, subscribe]); + ); + } + return () => {}; + }, [isActive, callback, captureOnInputs, excludedNodes, priority, shortcut.descriptionKey, shortcut.modifiers, shortcut.shortcutKey, shouldBubble, shouldPreventDefault]); } diff --git a/src/languages/en.js b/src/languages/en.js index 151ae3c03e6a..daeb79db268b 100755 --- a/src/languages/en.js +++ b/src/languages/en.js @@ -144,6 +144,7 @@ export default { per: 'per', mi: 'mile', km: 'kilometer', + copied: 'Copied!', }, anonymousReportFooter: { logoTagline: 'Join in on the discussion.', @@ -817,6 +818,7 @@ export default { 'In order to finish setting up your bank account, you must validate your account. Please check your email to validate your account, and return here to finish up!', hasPhoneLoginError: 'To add a verified bank account please ensure your primary login is a valid email and try again. You can add your phone number as a secondary login.', hasBeenThrottledError: 'There was an error adding your bank account. Please wait a few minutes and try again.', + hasCurrencyError: 'Oops! It appears that your workspace currency is set to a different currency than USD. To proceed, please set it to USD and try again', error: { noBankAccountAvailable: 'Sorry, no bank account is available', noBankAccountSelected: 'Please choose an account', @@ -1071,8 +1073,6 @@ export default { reconcileCards: 'Reconcile cards', settlementFrequency: 'Settlement frequency', deleteConfirmation: 'Are you sure you want to delete this workspace?', - growlMessageOnDelete: 'Workspace deleted', - growlMessageOnDeleteError: 'This workspace cannot be deleted right now because reports are actively being processed', unavailable: 'Unavailable workspace', memberNotFound: 'Member not found. To invite a new member to the workspace, please use the Invite button above.', notAuthorized: `You do not have access to this page. Are you trying to join the workspace? Please reach out to the owner of this workspace so they can add you as a member! Something else? Reach out to ${CONST.EMAIL.CONCIERGE}`, @@ -1205,6 +1205,9 @@ export default { bankAccountAnyTransactions: ' bank account. Any outstanding transactions for this account will still complete.', clearProgress: 'Starting over will clear the progress you have made so far.', areYouSure: 'Are you sure?', + workspaceCurrency: 'Workspace currency', + updateCurrencyPrompt: 'It looks like your Workspace is currently set to a different currency than USD. Please click the button below to update your currency to USD now.', + updateToUSD: 'Update to USD', }, }, getAssistancePage: { @@ -1399,6 +1402,7 @@ export default { chatUserDisplayNames: 'Chat user display names', scrollToNewestMessages: 'Scroll to newest messages', prestyledText: 'Prestyled text', + viewAttachment: 'View attachment', }, parentReportAction: { deletedMessage: '[Deleted message]', diff --git a/src/languages/es.js b/src/languages/es.js index 18c94ef3c23c..f16ef5b441e6 100644 --- a/src/languages/es.js +++ b/src/languages/es.js @@ -143,6 +143,7 @@ export default { per: 'por', mi: 'milla', km: 'kilómetro', + copied: '¡Copiado!', }, anonymousReportFooter: { logoTagline: 'Únete a la discussion.', @@ -819,6 +820,8 @@ export default { hasPhoneLoginError: 'Para agregar una cuenta bancaria verificada, asegúrate de que tu nombre de usuario principal sea un correo electrónico válido y vuelve a intentarlo. Puedes agregar tu número de teléfono como nombre de usuario secundario.', hasBeenThrottledError: 'Se produjo un error al intentar agregar tu cuenta bancaria. Por favor, espera unos minutos e inténtalo de nuevo.', + hasCurrencyError: + '¡Ups! Parece que la moneda de tu espacio de trabajo está configurada en una moneda diferente a USD. Para continuar, por favor configúrala en USD e inténtalo nuevamente.', error: { noBankAccountAvailable: 'Lo sentimos, no hay ninguna cuenta bancaria disponible', noBankAccountSelected: 'Por favor, elige una cuenta bancaria', @@ -1075,9 +1078,7 @@ export default { issueAndManageCards: 'Emitir y gestionar tarjetas', reconcileCards: 'Reconciliar tarjetas', settlementFrequency: 'Frecuencia de liquidación', - growlMessageOnDelete: 'Espacio de trabajo eliminado', deleteConfirmation: '¿Estás seguro de que quieres eliminar este espacio de trabajo?', - growlMessageOnDeleteError: 'No se puede eliminar el espacio de trabajo porque tiene informes que están siendo procesados', unavailable: 'Espacio de trabajo no disponible', memberNotFound: 'Miembro no encontrado. Para invitar a un nuevo miembro al espacio de trabajo, por favor, utiliza el botón Invitar que está arriba.', notAuthorized: `No tienes acceso a esta página. ¿Estás tratando de unirte al espacio de trabajo? Comunícate con el propietario de este espacio de trabajo para que pueda agregarte como miembro. ¿Necesitas algo más? Comunícate con ${CONST.EMAIL.CONCIERGE}`, @@ -1211,6 +1212,10 @@ export default { bankAccountAnyTransactions: '. Los reembolsos pendientes serán completados sin problemas.', clearProgress: 'Empezar de nuevo descartará lo completado hasta ahora.', areYouSure: '¿Estás seguro?', + workspaceCurrency: 'Moneda del espacio de trabajo', + updateCurrencyPrompt: + 'Parece que tu espacio de trabajo está configurado actualmente en una moneda diferente a USD. Por favor, haz clic en el botón de abajo para actualizar tu moneda a USD ahora.', + updateToUSD: 'Actualizar a USD', }, }, getAssistancePage: { @@ -1865,6 +1870,7 @@ export default { chatUserDisplayNames: 'Nombres de los usuarios del chat', scrollToNewestMessages: 'Desplázate a los mensajes más recientes', prestyledText: 'texto preestilizado', + viewAttachment: 'Ver archivo adjunto', }, parentReportAction: { deletedMessage: '[Mensaje eliminado]', diff --git a/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator.js b/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator.js index 471be5c7209c..64eadcbe06c3 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator.js +++ b/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator.js @@ -17,7 +17,7 @@ function CentralPaneNavigator() { { state.index = state.routes.length - 1; }; -const CustomRouter = (options) => { +function CustomRouter(options) { const stackRouter = StackRouter(options); return { @@ -37,6 +37,6 @@ const CustomRouter = (options) => { return state; }, }; -}; +} export default CustomRouter; diff --git a/src/libs/Navigation/FreezeWrapper.js b/src/libs/Navigation/FreezeWrapper.js index f4f0072e2ac8..07b05651a769 100644 --- a/src/libs/Navigation/FreezeWrapper.js +++ b/src/libs/Navigation/FreezeWrapper.js @@ -3,11 +3,12 @@ import lodashFindIndex from 'lodash/findIndex'; import PropTypes from 'prop-types'; import {useIsFocused, useNavigation, useRoute} from '@react-navigation/native'; import {Freeze} from 'react-freeze'; -import withWindowDimensions, {windowDimensionsPropTypes} from '../../components/withWindowDimensions'; const propTypes = { - ...windowDimensionsPropTypes, + /** Prop to disable freeze */ keepVisible: PropTypes.bool, + /** Children to wrap in FreezeWrapper. */ + children: PropTypes.node.isRequired, }; const defaultProps = { @@ -47,5 +48,6 @@ function FreezeWrapper(props) { FreezeWrapper.propTypes = propTypes; FreezeWrapper.defaultProps = defaultProps; +FreezeWrapper.displayName = 'FreezeWrapper'; -export default withWindowDimensions(FreezeWrapper); +export default FreezeWrapper; diff --git a/src/libs/Navigation/NavigationRoot.js b/src/libs/Navigation/NavigationRoot.js index f81a9fd2336d..4f32178ba5bd 100644 --- a/src/libs/Navigation/NavigationRoot.js +++ b/src/libs/Navigation/NavigationRoot.js @@ -8,7 +8,7 @@ import AppNavigator from './AppNavigator'; import themeColors from '../../styles/themes/default'; import withWindowDimensions, {windowDimensionsPropTypes} from '../../components/withWindowDimensions'; import Log from '../Log'; -import withCurrentReportId from '../../components/withCurrentReportId'; +import withCurrentReportId, {withCurrentReportIdPropTypes} from '../../components/withCurrentReportId'; import compose from '../compose'; // https://reactnavigation.org/docs/themes @@ -28,6 +28,7 @@ const propTypes = { /** Fired when react-navigation is ready */ onReady: PropTypes.func.isRequired, + ...withCurrentReportIdPropTypes, }; /** diff --git a/src/libs/OptionsListUtils.js b/src/libs/OptionsListUtils.js index 8669eea4703a..9934880eb905 100644 --- a/src/libs/OptionsListUtils.js +++ b/src/libs/OptionsListUtils.js @@ -159,6 +159,7 @@ function getAvatarsForAccountIDs(accountIDs, personalDetails) { return _.map(accountIDs, (accountID) => { const userPersonalDetail = lodashGet(personalDetails, accountID, {login: '', accountID, avatar: ''}); return { + id: accountID, source: UserUtils.getAvatar(userPersonalDetail.avatar, userPersonalDetail.accountID), type: CONST.ICON_TYPE_AVATAR, name: userPersonalDetail.login, @@ -859,7 +860,7 @@ function getIOUConfirmationOptionsFromPayeePersonalDetail(personalDetail, amount alternateText: personalDetail.login, icons: [ { - source: UserUtils.getAvatar(personalDetail.avatar, personalDetail.login), + source: UserUtils.getAvatar(personalDetail.avatar, personalDetail.accountID), name: personalDetail.login, type: CONST.ICON_TYPE_AVATAR, }, diff --git a/src/libs/ReportActionsUtils.js b/src/libs/ReportActionsUtils.js index b5cdefa115d0..f57084c9f0e0 100644 --- a/src/libs/ReportActionsUtils.js +++ b/src/libs/ReportActionsUtils.js @@ -65,7 +65,7 @@ function hasCommentThread(reportAction) { } /** - * Returns the parentReportAction if the given report is a thread. + * Returns the parentReportAction if the given report is a thread/task. * * @param {Object} report * @returns {Object} diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js index 02c3308898f8..996c97e18269 100644 --- a/src/libs/ReportUtils.js +++ b/src/libs/ReportUtils.js @@ -683,19 +683,25 @@ function getIconsForParticipants(participants, personalDetails) { for (let i = 0; i < participantsList.length; i++) { const accountID = participantsList[i]; const avatarSource = UserUtils.getAvatar(lodashGet(personalDetails, [accountID, 'avatar'], ''), accountID); - participantDetails.push([lodashGet(personalDetails, [accountID, 'login'], ''), lodashGet(personalDetails, [accountID, 'firstName'], ''), avatarSource]); + participantDetails.push([ + accountID, + lodashGet(personalDetails, [accountID, 'login'], lodashGet(personalDetails, [accountID, 'displayName'], '')), + lodashGet(personalDetails, [accountID, 'firstName'], ''), + avatarSource, + ]); } // Sort all logins by first name (which is the second element in the array) - const sortedParticipantDetails = participantDetails.sort((a, b) => a[1] - b[1]); + const sortedParticipantDetails = participantDetails.sort((a, b) => a[2] - b[2]); // Now that things are sorted, gather only the avatars (third element in the array) and return those const avatars = []; for (let i = 0; i < sortedParticipantDetails.length; i++) { const userIcon = { - source: sortedParticipantDetails[i][2], + id: sortedParticipantDetails[i][0], + source: sortedParticipantDetails[i][3], type: CONST.ICON_TYPE_AVATAR, - name: sortedParticipantDetails[i][0], + name: sortedParticipantDetails[i][1], }; avatars.push(userIcon); } @@ -724,11 +730,6 @@ function getIcons(report, personalDetails, defaultIcon = null, isPayer = false) result.source = defaultIcon || Expensicons.FallbackAvatar; return [result]; } - if (isConciergeChatReport(report)) { - result.source = CONST.CONCIERGE_ICON_URL; - result.name = CONST.EMAIL.CONCIERGE; - return [result]; - } if (isArchivedRoom(report)) { result.source = Expensicons.DeletedRoomAvatar; return [result]; @@ -739,6 +740,7 @@ function getIcons(report, personalDetails, defaultIcon = null, isPayer = false) const actorEmail = lodashGet(parentReportAction, 'actorEmail', ''); const actorAccountID = lodashGet(parentReportAction, 'actorAccountID', ''); const actorIcon = { + id: actorAccountID, source: UserUtils.getAvatar(lodashGet(personalDetails, [actorAccountID, 'avatar']), actorAccountID), name: actorEmail, type: CONST.ICON_TYPE_AVATAR, @@ -749,6 +751,7 @@ function getIcons(report, personalDetails, defaultIcon = null, isPayer = false) if (isTaskReport(report)) { const ownerEmail = report.ownerEmail || ''; const ownerIcon = { + id: report.ownerAccountID, source: UserUtils.getAvatar(lodashGet(personalDetails, [report.ownerAccountID, 'avatar']), report.ownerAccountID), name: ownerEmail, type: CONST.ICON_TYPE_AVATAR, @@ -786,6 +789,7 @@ function getIcons(report, personalDetails, defaultIcon = null, isPayer = false) } const adminIcon = { + id: report.ownerAccountID, source: UserUtils.getAvatar(lodashGet(personalDetails, [report.ownerAccountID, 'avatar']), report.ownerAccountID), name: report.ownerEmail, type: CONST.ICON_TYPE_AVATAR, @@ -806,6 +810,7 @@ function getIcons(report, personalDetails, defaultIcon = null, isPayer = false) const accountID = isPayer ? report.managerID : report.ownerAccountID; return [ { + id: accountID, source: UserUtils.getAvatar(lodashGet(personalDetails, [accountID, 'avatar']), accountID), name: email, type: CONST.ICON_TYPE_AVATAR, @@ -841,16 +846,6 @@ function getPersonalDetailsForAccountID(accountID) { ); } -/** - * Gets the accountID for a login by looking in the ONYXKEYS.PERSONAL_DETAILS Onyx key (stored in the local variable, allPersonalDetails). If it doesn't exist in Onyx, - * then an empty string is returned. - * @param {String} login - * @returns {String} - */ -function getAccountIDForLogin(login) { - return lodashGet(allPersonalDetails, [login, 'accountID'], ''); -} - /** * Get the displayName for a single report participant. * @@ -2248,7 +2243,6 @@ function getParentReport(report) { } export { - getAccountIDForLogin, getReportParticipantsTitle, isReportMessageAttachment, findLastAccessedReport, diff --git a/src/libs/actions/IOU.js b/src/libs/actions/IOU.js index e0e4f46ca486..eecd26069724 100644 --- a/src/libs/actions/IOU.js +++ b/src/libs/actions/IOU.js @@ -231,11 +231,15 @@ function buildOnyxDataForMoneyRequest( [chatCreatedAction.reportActionID]: { errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage'), }, + [reportPreviewAction.reportActionID]: { + errors: ErrorUtils.getMicroSecondOnyxError(null), + }, } - : {}), - [reportPreviewAction.reportActionID]: { - created: reportPreviewAction.created, - }, + : { + [reportPreviewAction.reportActionID]: { + created: reportPreviewAction.created, + }, + }), }, }, { @@ -247,6 +251,9 @@ function buildOnyxDataForMoneyRequest( [iouCreatedAction.reportActionID]: { errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage'), }, + [iouAction.reportActionID]: { + errors: ErrorUtils.getMicroSecondOnyxError(null), + }, } : { [iouAction.reportActionID]: { @@ -494,15 +501,6 @@ function createSplitsAndOnyxData(participants, currentUserLogin, currentUserAcco } const failureData = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${groupChatReport.reportID}`, - value: { - [groupIOUReportAction.reportActionID]: { - errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage'), - }, - }, - }, { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.TRANSACTION}${groupTransaction.transactionID}`, @@ -512,16 +510,37 @@ function createSplitsAndOnyxData(participants, currentUserLogin, currentUserAcco }, ]; - if (!existingGroupChatReport) { + if (existingGroupChatReport) { failureData.push({ onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${groupChatReport.reportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${groupChatReport.reportID}`, value: { - errorFields: { - createChat: ErrorUtils.getMicroSecondOnyxError('report.genericCreateReportFailureMessage'), + [groupIOUReportAction.reportActionID]: { + errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage'), }, }, }); + } else { + failureData.push( + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${groupChatReport.reportID}`, + value: { + errorFields: { + createChat: ErrorUtils.getMicroSecondOnyxError('report.genericCreateReportFailureMessage'), + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${groupChatReport.reportID}`, + value: { + [groupIOUReportAction.reportActionID]: { + errors: ErrorUtils.getMicroSecondOnyxError(null), + }, + }, + }, + ); } // Loop through participants creating individual chats, iouReports and reportActionIDs as needed @@ -951,15 +970,6 @@ function getSendMoneyParams(report, amount, currency, comment, paymentMethodType ]; const failureData = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${optimisticIOUReport.reportID}`, - value: { - [optimisticIOUReportAction.reportActionID]: { - errors: ErrorUtils.getMicroSecondOnyxError('iou.error.other'), - }, - }, - }, { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.TRANSACTION}${optimisticTransaction.transactionID}`, @@ -986,15 +996,26 @@ function getSendMoneyParams(report, amount, currency, comment, paymentMethodType key: optimisticChatReportData.key, value: {pendingFields: null}, }); - failureData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: optimisticChatReportData.key, - value: { - errorFields: { - createChat: ErrorUtils.getMicroSecondOnyxError('report.genericCreateReportFailureMessage'), + failureData.push( + { + onyxMethod: Onyx.METHOD.MERGE, + key: optimisticChatReportData.key, + value: { + errorFields: { + createChat: ErrorUtils.getMicroSecondOnyxError('report.genericCreateReportFailureMessage'), + }, }, }, - }); + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${optimisticIOUReport.reportID}`, + value: { + [optimisticIOUReportAction.reportActionID]: { + errors: ErrorUtils.getMicroSecondOnyxError(null), + }, + }, + }, + ); // Add optimistic personal details for recipient optimisticPersonalDetailListData = { @@ -1012,9 +1033,16 @@ function getSendMoneyParams(report, amount, currency, comment, paymentMethodType // Add an optimistic created action to the optimistic reportActions data optimisticReportActionsData.value[optimisticCreatedAction.reportActionID] = optimisticCreatedAction; - - // If we're going to fail to create the report itself, let's not have redundant error messages for the IOU - failureData[0].value[optimisticIOUReportAction.reportActionID] = {pendingAction: null}; + } else { + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${optimisticIOUReport.reportID}`, + value: { + [optimisticIOUReportAction.reportActionID]: { + errors: ErrorUtils.getMicroSecondOnyxError('iou.error.other'), + }, + }, + }); } const optimisticData = [optimisticChatReportData, optimisticIOUReportData, optimisticReportActionsData, optimisticTransactionData]; diff --git a/src/libs/actions/Policy.js b/src/libs/actions/Policy.js index f9092f87c48b..8d253a36a771 100644 --- a/src/libs/actions/Policy.js +++ b/src/libs/actions/Policy.js @@ -531,9 +531,12 @@ function clearAvatarErrors(policyID) { function updateGeneralSettings(policyID, name, currency) { const optimisticData = [ { - onyxMethod: Onyx.METHOD.MERGE, + // We use SET because it's faster than merge and avoids a race condition when setting the currency and navigating the user to the Bank account page in confirmCurrencyChangeAndHideModal + onyxMethod: Onyx.METHOD.SET, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, value: { + ...allPolicies[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`], + pendingFields: { generalSettings: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, }, diff --git a/src/libs/actions/Report.js b/src/libs/actions/Report.js index 8c62046dea5a..5f24bcf7c409 100644 --- a/src/libs/actions/Report.js +++ b/src/libs/actions/Report.js @@ -67,6 +67,14 @@ Onyx.connect({ callback: (val) => (isNetworkOffline = lodashGet(val, 'isOffline', false)), }); +let allPersonalDetails; +Onyx.connect({ + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + callback: (val) => { + allPersonalDetails = val || {}; + }, +}); + const allReports = {}; let conciergeChatReportID; const typingWatchTimers = {}; @@ -420,7 +428,7 @@ function openReport(reportID, participantLoginList = [], newReportObject = {}, p const optimisticPersonalDetails = {}; _.map(participantLoginList, (login, index) => { const accountID = newReportObject.participantAccountIDs[index]; - optimisticPersonalDetails[accountID] = { + optimisticPersonalDetails[accountID] = allPersonalDetails[accountID] || { login, accountID, avatar: UserUtils.getDefaultAvatarURL(accountID), diff --git a/src/libs/actions/Task.js b/src/libs/actions/Task.js index 4003ba8002f0..c31f1617349d 100644 --- a/src/libs/actions/Task.js +++ b/src/libs/actions/Task.js @@ -10,6 +10,8 @@ import ROUTES from '../../ROUTES'; import CONST from '../../CONST'; import DateUtils from '../DateUtils'; import * as UserUtils from '../UserUtils'; +import * as PersonalDetailsUtils from '../PersonalDetailsUtils'; +import * as ReportActionsUtils from '../ReportActionsUtils'; /** * Clears out the task info from the store @@ -627,6 +629,47 @@ function dismissModalAndClearOutTaskInfo() { clearOutTaskInfo(); } +/** + * Returns Task assignee accountID + * + * @param {Object} taskReport + * @returns {Number|null} + */ +function getTaskAssigneeAccountID(taskReport) { + if (!taskReport) { + return null; + } + + if (taskReport.managerID) { + return taskReport.managerID; + } + + const reportAction = ReportActionsUtils.getParentReportAction(taskReport); + const childManagerEmail = lodashGet(reportAction, 'childManagerEmail', ''); + return PersonalDetailsUtils.getAccountIDsByLogins([childManagerEmail])[0]; +} + +/** + * Returns Task owner accountID + * + * @param {Object} taskReport + * @returns {Number|null} + */ +function getTaskOwnerAccountID(taskReport) { + return lodashGet(taskReport, 'ownerAccountID', null); +} + +/** + * Check if current user is either task assignee or task owner + * + * @param {Object} taskReport + * @param {Number} sessionAccountID + * @returns {Boolean} + */ +function isTaskAssigneeOrTaskOwner(taskReport, sessionAccountID) { + return sessionAccountID === getTaskOwnerAccountID(taskReport) || sessionAccountID === getTaskAssigneeAccountID(taskReport); +} + export { createTaskAndNavigate, editTaskAndNavigate, @@ -646,4 +689,6 @@ export { cancelTask, isTaskCanceled, dismissModalAndClearOutTaskInfo, + getTaskAssigneeAccountID, + isTaskAssigneeOrTaskOwner, }; diff --git a/src/pages/DetailsPage.js b/src/pages/DetailsPage.js index 2b5be4121760..ac42017d3c7c 100755 --- a/src/pages/DetailsPage.js +++ b/src/pages/DetailsPage.js @@ -22,7 +22,7 @@ import * as ReportUtils from '../libs/ReportUtils'; import * as Expensicons from '../components/Icon/Expensicons'; import MenuItem from '../components/MenuItem'; import AttachmentModal from '../components/AttachmentModal'; -import PressableWithoutFocus from '../components/PressableWithoutFocus'; +import PressableWithoutFocus from '../components/Pressable/PressableWithoutFocus'; import * as Report from '../libs/actions/Report'; import OfflineWithFeedback from '../components/OfflineWithFeedback'; import AutoUpdateTime from '../components/AutoUpdateTime'; diff --git a/src/pages/NewChatPage.js b/src/pages/NewChatPage.js index a0244f72fae7..76a817acc334 100755 --- a/src/pages/NewChatPage.js +++ b/src/pages/NewChatPage.js @@ -14,6 +14,7 @@ import withWindowDimensions, {windowDimensionsPropTypes} from '../components/wit import HeaderWithBackButton from '../components/HeaderWithBackButton'; import ScreenWrapper from '../components/ScreenWrapper'; import withLocalize, {withLocalizePropTypes} from '../components/withLocalize'; +import * as Browser from '../libs/Browser'; import compose from '../libs/compose'; import personalDetailsPropType from './personalDetailsPropType'; import reportPropTypes from './reportPropTypes'; @@ -242,7 +243,7 @@ class NewChatPage extends Component { onChangeText={this.updateOptionsWithSearchTerm} headerMessage={headerMessage} boldStyle - shouldFocusOnSelectRow={this.props.isGroupChat} + shouldFocusOnSelectRow={this.props.isGroupChat && !Browser.isMobile()} shouldShowConfirmButton={this.props.isGroupChat} shouldShowOptions={didScreenTransitionEnd && isOptionsDataReady} confirmButtonText={this.props.translate('newChatPage.createGroup')} diff --git a/src/pages/ProfilePage.js b/src/pages/ProfilePage.js index ac0d1db4d14b..41a5d153f0cc 100755 --- a/src/pages/ProfilePage.js +++ b/src/pages/ProfilePage.js @@ -23,7 +23,7 @@ import * as ReportUtils from '../libs/ReportUtils'; import * as Expensicons from '../components/Icon/Expensicons'; import MenuItem from '../components/MenuItem'; import AttachmentModal from '../components/AttachmentModal'; -import PressableWithoutFocus from '../components/PressableWithoutFocus'; +import PressableWithoutFocus from '../components/Pressable/PressableWithoutFocus'; import * as Report from '../libs/actions/Report'; import OfflineWithFeedback from '../components/OfflineWithFeedback'; import AutoUpdateTime from '../components/AutoUpdateTime'; @@ -148,8 +148,10 @@ function ProfilePage(props) { > {({show}) => ( - {this.props.translate('bankAccount.hasPhoneLoginError')} - + if (this.state.shouldShowContinueSetupButton) { + return ( + ); } + let errorText; + const userHasPhonePrimaryEmail = Str.endsWith(this.props.session.email, CONST.SMS.DOMAIN); const throttledDate = lodashGet(this.props.reimbursementAccount, 'throttledDate'); - if (throttledDate) { - errorComponent = ( - - {this.props.translate('bankAccount.hasBeenThrottledError')} - - ); + const hasUnsupportedCurrency = lodashGet(this.props.policy, 'outputCurrency', '') !== CONST.CURRENCY.USD; + + if (userHasPhonePrimaryEmail) { + errorText = this.props.translate('bankAccount.hasPhoneLoginError'); + } else if (throttledDate) { + errorText = this.props.translate('bankAccount.hasBeenThrottledError'); + } else if (hasUnsupportedCurrency) { + errorText = this.props.translate('bankAccount.hasCurrencyError'); } - if (errorComponent) { + if (errorText) { return ( Navigation.goBack(ROUTES.SETTINGS_WORKSPACES)} /> - {errorComponent} + + {errorText} + ); } diff --git a/src/pages/ReportParticipantsPage.js b/src/pages/ReportParticipantsPage.js index da065dadcb77..cdcaabcfe34d 100755 --- a/src/pages/ReportParticipantsPage.js +++ b/src/pages/ReportParticipantsPage.js @@ -67,6 +67,7 @@ const getAllParticipants = (report, personalDetails) => { accountID: userPersonalDetail.accountID, icons: [ { + id: accountID, source: UserUtils.getAvatar(userPersonalDetail.avatar, accountID), name: userLogin, type: CONST.ICON_TYPE_AVATAR, diff --git a/src/pages/home/HeaderView.js b/src/pages/home/HeaderView.js index b12733f64ef8..15ae1d2c64ec 100644 --- a/src/pages/home/HeaderView.js +++ b/src/pages/home/HeaderView.js @@ -51,6 +51,11 @@ const propTypes = { guideCalendarLink: PropTypes.string, }), + /** Current user session */ + session: PropTypes.shape({ + accountID: PropTypes.number, + }), + /** The report actions from the parent report */ // TO DO: Replace with HOC https://github.com/Expensify/App/issues/18769. // eslint-disable-next-line react/no-unused-prop-types @@ -68,6 +73,9 @@ const defaultProps = { guideCalendarLink: null, }, parentReport: {}, + session: { + accountID: 0, + }, }; function HeaderView(props) { @@ -91,7 +99,8 @@ function HeaderView(props) { const shouldShowCallButton = (isConcierge && guideCalendarLink) || (!isAutomatedExpensifyAccount && !isTaskReport); const threeDotMenuItems = []; if (isTaskReport) { - if (props.report.stateNum === CONST.REPORT.STATE_NUM.OPEN && props.report.statusNum === CONST.REPORT.STATUS.OPEN) { + const isTaskAssigneeOrTaskOwner = Task.isTaskAssigneeOrTaskOwner(props.report, props.session.accountID); + if (props.report.stateNum === CONST.REPORT.STATE_NUM.OPEN && props.report.statusNum === CONST.REPORT.STATUS.OPEN && isTaskAssigneeOrTaskOwner) { threeDotMenuItems.push({ icon: Expensicons.Checkmark, text: props.translate('newTaskPage.markAsDone'), @@ -100,7 +109,7 @@ function HeaderView(props) { } // Task is marked as completed - if (props.report.stateNum === CONST.REPORT.STATE_NUM.SUBMITTED && props.report.statusNum === CONST.REPORT.STATUS.APPROVED) { + if (props.report.stateNum === CONST.REPORT.STATE_NUM.SUBMITTED && props.report.statusNum === CONST.REPORT.STATUS.APPROVED && isTaskAssigneeOrTaskOwner) { threeDotMenuItems.push({ icon: Expensicons.Checkmark, text: props.translate('newTaskPage.markAsIncomplete'), @@ -109,7 +118,7 @@ function HeaderView(props) { } // Task is not closed - if (props.report.stateNum !== CONST.REPORT.STATE_NUM.SUBMITTED && props.report.statusNum !== CONST.REPORT.STATUS.CLOSED) { + if (props.report.stateNum !== CONST.REPORT.STATE_NUM.SUBMITTED && props.report.statusNum !== CONST.REPORT.STATUS.CLOSED && isTaskAssigneeOrTaskOwner) { threeDotMenuItems.push({ icon: Expensicons.Trashcan, text: props.translate('common.cancel'), @@ -258,5 +267,8 @@ export default compose( parentReport: { key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT}${report.parentReportID || report.reportID}`, }, + session: { + key: ONYXKEYS.SESSION, + }, }), )(HeaderView); diff --git a/src/pages/home/report/ReactionList/BaseReactionList.js b/src/pages/home/report/ReactionList/BaseReactionList.js index 56ea9b23708e..87d68780e836 100755 --- a/src/pages/home/report/ReactionList/BaseReactionList.js +++ b/src/pages/home/report/ReactionList/BaseReactionList.js @@ -83,16 +83,16 @@ function BaseReactionList(props) { }} option={{ text: Str.removeSMSDomain(item.displayName), - alternateText: Str.removeSMSDomain(item.login), + alternateText: Str.removeSMSDomain(item.login || ''), participantsList: [item], icons: [ { - source: UserUtils.getAvatar(item.avatar, item.login), + source: UserUtils.getAvatar(item.avatar, item.accountID), name: item.login, type: CONST.ICON_TYPE_AVATAR, }, ], - keyForList: item.login, + keyForList: item.login || String(item.accountID), }} /> ); diff --git a/src/pages/home/report/ReportActionItemMessageEdit.js b/src/pages/home/report/ReportActionItemMessageEdit.js index c5b092487d70..56b78a73ae50 100644 --- a/src/pages/home/report/ReportActionItemMessageEdit.js +++ b/src/pages/home/report/ReportActionItemMessageEdit.js @@ -208,14 +208,18 @@ function ReportActionItemMessageEdit(props) { const trimmedNewDraft = draft.trim(); + // If the reportActionID and parentReportActionID are the same then the user is editing the first message of a + // thread and we should pass the parentReportID instead of the reportID of the thread + const reportID = props.report.parentReportActionID === props.action.reportActionID ? props.report.parentReportID : props.reportID; + // When user tries to save the empty message, it will delete it. Prompt the user to confirm deleting. if (!trimmedNewDraft) { - ReportActionContextMenu.showDeleteModal(props.reportID, props.action, false, deleteDraft, () => InteractionManager.runAfterInteractions(() => textInputRef.current.focus())); + ReportActionContextMenu.showDeleteModal(reportID, props.action, false, deleteDraft, () => InteractionManager.runAfterInteractions(() => textInputRef.current.focus())); return; } - Report.editReportComment(props.reportID, props.action, trimmedNewDraft); + Report.editReportComment(reportID, props.action, trimmedNewDraft); deleteDraft(); - }, [props.action, debouncedSaveDraft, deleteDraft, draft, props.reportID]); + }, [props.action, debouncedSaveDraft, deleteDraft, draft, props.reportID, props.report]); /** * @param {String} emoji diff --git a/src/pages/home/report/ReportActionItemSingle.js b/src/pages/home/report/ReportActionItemSingle.js index fa299858c338..3e49dfe621c4 100644 --- a/src/pages/home/report/ReportActionItemSingle.js +++ b/src/pages/home/report/ReportActionItemSingle.js @@ -1,6 +1,6 @@ import lodashGet from 'lodash/get'; import React from 'react'; -import {View, Pressable} from 'react-native'; +import {View} from 'react-native'; import PropTypes from 'prop-types'; import _ from 'underscore'; import reportActionPropTypes from './reportActionPropTypes'; @@ -21,6 +21,7 @@ import CONST from '../../../CONST'; import SubscriptAvatar from '../../../components/SubscriptAvatar'; import reportPropTypes from '../../reportPropTypes'; import * as UserUtils from '../../../libs/UserUtils'; +import PressableWithoutFeedback from '../../../components/Pressable/PressableWithoutFeedback'; import UserDetailsTooltip from '../../../components/UserDetailsTooltip'; const propTypes = { @@ -85,11 +86,13 @@ function ReportActionItemSingle(props) { return ( - showUserDetails(actorAccountID)} + accessibilityLabel={actorEmail} + accessibilityRole="button" > {props.shouldShowSubscriptAvatar ? ( @@ -111,15 +114,17 @@ function ReportActionItemSingle(props) { )} - + {props.showHeader ? ( - showUserDetails(actorAccountID)} + accessibilityLabel={actorEmail} + accessibilityRole="button" > {_.map(personArray, (fragment, index) => ( ))} - + ) : null} diff --git a/src/pages/home/sidebar/SidebarLinks.js b/src/pages/home/sidebar/SidebarLinks.js index 57c41564230f..fdd2567c2c90 100644 --- a/src/pages/home/sidebar/SidebarLinks.js +++ b/src/pages/home/sidebar/SidebarLinks.js @@ -28,7 +28,7 @@ import SidebarUtils from '../../../libs/SidebarUtils'; import reportPropTypes from '../../reportPropTypes'; import OfflineWithFeedback from '../../../components/OfflineWithFeedback'; import withNavigationFocus from '../../../components/withNavigationFocus'; -import withCurrentReportId from '../../../components/withCurrentReportId'; +import withCurrentReportId, {withCurrentReportIdPropTypes} from '../../../components/withCurrentReportId'; import withNavigation, {withNavigationPropTypes} from '../../../components/withNavigation'; import Header from '../../../components/Header'; import defaultTheme from '../../../styles/themes/default'; @@ -84,6 +84,7 @@ const propTypes = { priorityMode: PropTypes.string, ...withLocalizePropTypes, + ...withCurrentReportIdPropTypes, ...withNavigationPropTypes, }; diff --git a/src/pages/settings/Report/ReportSettingsPage.js b/src/pages/settings/Report/ReportSettingsPage.js index ad57de80aaaa..53f0b99910bb 100644 --- a/src/pages/settings/Report/ReportSettingsPage.js +++ b/src/pages/settings/Report/ReportSettingsPage.js @@ -88,9 +88,7 @@ class ReportSettingsPage extends Component { * @returns {Boolean} */ shouldDisableWelcomeMessage(linkedWorkspace) { - return ( - ReportUtils.isArchivedRoom(this.props.report) || !ReportUtils.isChatRoom(this.props.report) || (!_.isEmpty(linkedWorkspace) && linkedWorkspace.role !== CONST.POLICY.ROLE.ADMIN) - ); + return ReportUtils.isArchivedRoom(this.props.report) || !ReportUtils.isChatRoom(this.props.report) || _.isEmpty(linkedWorkspace) || linkedWorkspace.role !== CONST.POLICY.ROLE.ADMIN; } render() { diff --git a/src/pages/settings/Security/TwoFactorAuth/CodesPage.js b/src/pages/settings/Security/TwoFactorAuth/CodesPage.js index 57057c17cff5..ec0631370d02 100644 --- a/src/pages/settings/Security/TwoFactorAuth/CodesPage.js +++ b/src/pages/settings/Security/TwoFactorAuth/CodesPage.js @@ -25,6 +25,7 @@ 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, @@ -67,7 +68,7 @@ function CodesPage(props) { onBackButtonPress={() => Navigation.goBack(ROUTES.SETTINGS_SECURITY)} /> - +
))} - + { @@ -123,15 +125,15 @@ function CodesPage(props) { )}
- -