diff --git a/src/components/PopoverWithMeasuredContent.js b/src/components/PopoverWithMeasuredContent.js index bf67ba3a3327..e795c42a6788 100644 --- a/src/components/PopoverWithMeasuredContent.js +++ b/src/components/PopoverWithMeasuredContent.js @@ -55,6 +55,7 @@ class PopoverWithMeasuredContent extends Component { this.state = { isContentMeasured: false, + isVisible: false, }; this.popoverWidth = 0; @@ -63,6 +64,26 @@ class PopoverWithMeasuredContent extends Component { this.measurePopover = this.measurePopover.bind(this); } + /** + * When Popover becomes visible, we need to recalculate the Dimensions. + * Skip render on Popover until recalculations have done by setting isContentMeasured false as early as possible. + * + * @static + * @param {Object} props + * @param {Object} state + * @return {Object|null} + */ + static getDerivedStateFromProps(props, state) { + // When Popover is shown recalculate + if (!state.isVisible && props.isVisible) { + return {isContentMeasured: false, isVisible: true}; + } + if (!props.isVisible) { + return {isVisible: false}; + } + return null; + } + shouldComponentUpdate(nextProps, nextState) { if (this.props.isVisible && (nextProps.windowWidth !== this.props.windowWidth diff --git a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.js b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.js new file mode 100755 index 000000000000..4f6b0dcf5ae6 --- /dev/null +++ b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.js @@ -0,0 +1,54 @@ +import React from 'react'; +import {View} from 'react-native'; +import _ from 'underscore'; +import getReportActionContextMenuStyles from '../../../../styles/getReportActionContextMenuStyles'; +import ContextMenuItem from '../../../../components/ContextMenuItem'; +import { + propTypes as GenericReportActionContextMenuPropTypes, + defaultProps, +} from './GenericReportActionContextMenuPropTypes'; +import withLocalize, {withLocalizePropTypes} from '../../../../components/withLocalize'; +import ContextMenuActions from './ContextMenuActions'; + +const propTypes = { + ...GenericReportActionContextMenuPropTypes, + ...withLocalizePropTypes, +}; + +class BaseReportActionContextMenu extends React.Component { + constructor(props) { + super(props); + + this.wrapperStyle = getReportActionContextMenuStyles(this.props.isMini); + } + + render() { + return this.props.isVisible && ( + + {_.map(ContextMenuActions, contextAction => contextAction.shouldShow(this.props.reportAction) && ( + contextAction.onPress(!this.props.isMini, { + reportAction: this.props.reportAction, + reportID: this.props.reportID, + draftMessage: this.props.draftMessage, + selection: this.props.selection, + })} + /> + ))} + + ); + } +} + +BaseReportActionContextMenu.propTypes = propTypes; +BaseReportActionContextMenu.defaultProps = defaultProps; + +export default withLocalize(BaseReportActionContextMenu); diff --git a/src/pages/home/report/ContextMenu/ContextMenuActions.js b/src/pages/home/report/ContextMenu/ContextMenuActions.js new file mode 100644 index 000000000000..1123e54f2623 --- /dev/null +++ b/src/pages/home/report/ContextMenu/ContextMenuActions.js @@ -0,0 +1,116 @@ +import _ from 'underscore'; +import lodashGet from 'lodash/get'; +import Str from 'expensify-common/lib/str'; +import { + Clipboard as ClipboardIcon, LinkCopy, Mail, Pencil, Trashcan, Checkmark, +} from '../../../../components/Icon/Expensicons'; +import { + setNewMarkerPosition, updateLastReadActionID, saveReportActionDraft, +} from '../../../../libs/actions/Report'; +import Clipboard from '../../../../libs/Clipboard'; +import {isReportMessageAttachment, canEditReportAction, canDeleteReportAction} from '../../../../libs/reportUtils'; +import ReportActionComposeFocusManager from '../../../../libs/ReportActionComposeFocusManager'; +import {hideContextMenu, showDeleteModal} from './ReportActionContextMenu'; + +/** + * Gets the HTML version of the message in an action. + * @param {Object} reportAction + * @return {String} + */ +function getActionText(reportAction) { + const message = _.last(lodashGet(reportAction, 'message', null)); + return lodashGet(message, 'html', ''); +} + +// A list of all the context actions in this menu. +export default [ + // Copy to clipboard + { + textTranslateKey: 'contextMenuItem.copyToClipboard', + icon: ClipboardIcon, + successTextTranslateKey: 'contextMenuItem.copied', + successIcon: Checkmark, + shouldShow: () => true, + + // If return value is true, we switch the `text` and `icon` on + // `ContextMenuItem` with `successText` and `successIcon` which will fallback to + // the `text` and `icon` + onPress: (closePopover, {reportAction, selection}) => { + const message = _.last(lodashGet(reportAction, 'message', null)); + const html = lodashGet(message, 'html', ''); + const text = Str.htmlDecode(selection || lodashGet(message, 'text', '')); + const isAttachment = _.has(reportAction, 'isAttachment') + ? reportAction.isAttachment + : isReportMessageAttachment(text); + if (!isAttachment) { + Clipboard.setString(text); + } else { + Clipboard.setString(html); + } + if (closePopover) { + hideContextMenu(true, ReportActionComposeFocusManager.focus); + } + }, + }, + + { + textTranslateKey: 'reportActionContextMenu.copyLink', + icon: LinkCopy, + shouldShow: () => false, + onPress: () => {}, + }, + + { + textTranslateKey: 'reportActionContextMenu.markAsUnread', + icon: Mail, + successIcon: Checkmark, + shouldShow: () => true, + onPress: (closePopover, {reportAction, reportID}) => { + updateLastReadActionID(reportID, reportAction.sequenceNumber); + setNewMarkerPosition(reportID, reportAction.sequenceNumber); + if (closePopover) { + hideContextMenu(true, ReportActionComposeFocusManager.focus); + } + }, + }, + + { + textTranslateKey: 'reportActionContextMenu.editComment', + icon: Pencil, + shouldShow: reportAction => canEditReportAction(reportAction), + onPress: (closePopover, {reportID, reportAction, draftMessage}) => { + const editAction = () => saveReportActionDraft( + reportID, + reportAction.reportActionID, + _.isEmpty(draftMessage) ? getActionText(reportAction) : '', + ); + + if (closePopover) { + // Hide popover, then call editAction + hideContextMenu(false, editAction); + return; + } + + // No popover to hide, call editAction immediately + editAction(); + }, + }, + { + textTranslateKey: 'reportActionContextMenu.deleteComment', + icon: Trashcan, + shouldShow: reportAction => canDeleteReportAction(reportAction), + onPress: (closePopover, {reportID, reportAction}) => { + if (closePopover) { + // Hide popover, then call showDeleteConfirmModal + hideContextMenu( + false, + () => showDeleteModal(reportID, reportAction), + ); + return; + } + + // No popover to hide, call showDeleteConfirmModal immediately + showDeleteModal(reportID, reportAction); + }, + }, +]; diff --git a/src/pages/home/report/ContextMenu/GenericReportActionContextMenuPropTypes.js b/src/pages/home/report/ContextMenu/GenericReportActionContextMenuPropTypes.js new file mode 100644 index 000000000000..ab21ecf8b7da --- /dev/null +++ b/src/pages/home/report/ContextMenu/GenericReportActionContextMenuPropTypes.js @@ -0,0 +1,32 @@ +import PropTypes from 'prop-types'; +import ReportActionPropTypes from '../ReportActionPropTypes'; + +const propTypes = { + /** The ID of the report this report action is attached to. */ + reportID: PropTypes.number.isRequired, + + /** The report action this context menu is attached to. */ + reportAction: PropTypes.shape(ReportActionPropTypes).isRequired, + + /** If true, this component will be a small, row-oriented menu that displays icons but not text. + If false, this component will be a larger, column-oriented menu that displays icons alongside text in each row. */ + isMini: PropTypes.bool, + + /** Controls the visibility of this component. */ + isVisible: PropTypes.bool, + + /** The copy selection of text. */ + selection: PropTypes.string, + + /** Draft message - if this is set the comment is in 'edit' mode */ + draftMessage: PropTypes.string, +}; + +const defaultProps = { + isMini: false, + isVisible: false, + selection: '', + draftMessage: '', +}; + +export {propTypes, defaultProps}; diff --git a/src/pages/home/report/ContextMenu/MiniReportActionContextMenu/index.js b/src/pages/home/report/ContextMenu/MiniReportActionContextMenu/index.js new file mode 100644 index 000000000000..a57825716bb7 --- /dev/null +++ b/src/pages/home/report/ContextMenu/MiniReportActionContextMenu/index.js @@ -0,0 +1,36 @@ +import _ from 'underscore'; +import React from 'react'; +import {View} from 'react-native'; +import PropTypes from 'prop-types'; +import { + propTypes as GenericReportActionContextMenuPropTypes, + defaultProps as GenericReportActionContextMenuDefaultProps, +} from '../GenericReportActionContextMenuPropTypes'; +import {getMiniReportActionContextMenuWrapperStyle} from '../../../../../styles/getReportActionItemStyles'; +import BaseReportActionContextMenu from '../BaseReportActionContextMenu'; + +const propTypes = { + ..._.omit(GenericReportActionContextMenuPropTypes, ['isMini']), + + /** Should the reportAction this menu is attached to have the appearance of being + * grouped with the previous reportAction? */ + displayAsGroup: PropTypes.bool, +}; + +const defaultProps = { + ..._.omit(GenericReportActionContextMenuDefaultProps, ['isMini']), + displayAsGroup: false, +}; + +const MiniReportActionContextMenu = props => ( + + {/* eslint-disable-next-line react/jsx-props-no-spreading */} + + +); + +MiniReportActionContextMenu.propTypes = propTypes; +MiniReportActionContextMenu.defaultProps = defaultProps; +MiniReportActionContextMenu.displayName = 'MiniReportActionContextMenu'; + +export default MiniReportActionContextMenu; diff --git a/src/pages/home/report/ContextMenu/MiniReportActionContextMenu/index.native.js b/src/pages/home/report/ContextMenu/MiniReportActionContextMenu/index.native.js new file mode 100644 index 000000000000..461f67a0a4bc --- /dev/null +++ b/src/pages/home/report/ContextMenu/MiniReportActionContextMenu/index.native.js @@ -0,0 +1 @@ +export default () => null; diff --git a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js new file mode 100644 index 000000000000..57bcae4d2130 --- /dev/null +++ b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js @@ -0,0 +1,261 @@ +import React from 'react'; +import { + Dimensions, +} from 'react-native'; +import _ from 'underscore'; +import { + deleteReportComment, +} from '../../../../libs/actions/Report'; +import withLocalize, {withLocalizePropTypes} from '../../../../components/withLocalize'; +import PopoverWithMeasuredContent from '../../../../components/PopoverWithMeasuredContent'; +import BaseReportActionContextMenu from './BaseReportActionContextMenu'; +import ConfirmModal from '../../../../components/ConfirmModal'; + +const propTypes = { + ...withLocalizePropTypes, +}; + +class PopoverReportActionContextMenu extends React.Component { + constructor(props) { + super(props); + + this.state = { + reportID: 0, + reportAction: {}, + selection: '', + reportActionDraftMessage: '', + isPopoverVisible: false, + isDeleteCommentConfirmModalVisible: false, + cursorRelativePosition: { + horizontal: 0, + vertical: 0, + }, + + // The horizontal and vertical position (relative to the screen) where the popover will display. + popoverAnchorPosition: { + horizontal: 0, + vertical: 0, + }, + }; + this.onPopoverHide = () => {}; + this.contextMenuAnchor = undefined; + this.showContextMenu = this.showContextMenu.bind(this); + this.hideContextMenu = this.hideContextMenu.bind(this); + this.measureContent = this.measureContent.bind(this); + this.measureContextMenuAnchorPosition = this.measureContextMenuAnchorPosition.bind(this); + this.confirmDeleteAndHideModal = this.confirmDeleteAndHideModal.bind(this); + this.hideDeleteModal = this.hideDeleteModal.bind(this); + this.showDeleteModal = this.showDeleteModal.bind(this); + this.runAfterContextMenuHide = this.runAfterContextMenuHide.bind(this); + this.getContextMenuMeasuredLocation = this.getContextMenuMeasuredLocation.bind(this); + this.isActiveReportAction = this.isActiveReportAction.bind(this); + } + + componentDidMount() { + Dimensions.addEventListener('change', this.measureContextMenuAnchorPosition); + } + + shouldComponentUpdate(nextProps, nextState) { + return this.state.isPopoverVisible !== nextState.isPopoverVisible + || this.state.popoverAnchorPosition !== nextState.popoverAnchorPosition + || this.state.isDeleteCommentConfirmModalVisible !== nextState.isDeleteCommentConfirmModalVisible; + } + + componentWillUnmount() { + Dimensions.removeEventListener('change', this.measureContextMenuAnchorPosition); + } + + /** + * Get the Context menu anchor position + * We calculate the achor coordinates from measureInWindow async method + * + * @returns {Promise} + */ + getContextMenuMeasuredLocation() { + return new Promise((resolve) => { + if (this.contextMenuAnchor) { + this.contextMenuAnchor.measureInWindow((x, y) => resolve({x, y})); + } else { + resolve({x: 0, y: 0}); + } + }); + } + + /** + * Whether Context Menu is active for the Report Action. + * + * @param {Number|String} actionID + * @return {Boolean} + */ + isActiveReportAction(actionID) { + return this.state.reportAction.reportActionID === actionID; + } + + /** + * Show the ReportActionContextMenu modal popover. + * + * @param {Object} [event] - A press event. + * @param {string} [selection] - A copy text. + * @param {Element} contextMenuAnchor - popoverAnchor + * @param {Number} reportID - Active Report Id + * @param {Object} reportAction - ReportAction for ContextMenu + * @param {String} draftMessage - ReportAction Draftmessage + * @param {Function} [onShow] - Run a callback when Menu is shown + * @param {Function} [onHide] - Run a callback when Menu is hidden + */ + showContextMenu( + event, + selection, + contextMenuAnchor, + reportID, + reportAction, + draftMessage, + onShow = () => {}, + onHide = () => {}, + ) { + const nativeEvent = event.nativeEvent || {}; + this.contextMenuAnchor = contextMenuAnchor; + this.onPopoverHide = onHide; + this.getContextMenuMeasuredLocation().then(({x, y}) => { + this.setState({ + cursorRelativePosition: { + horizontal: nativeEvent.pageX - x, + vertical: nativeEvent.pageY - y, + }, + popoverAnchorPosition: { + horizontal: nativeEvent.pageX, + vertical: nativeEvent.pageY, + }, + reportID, + reportAction, + selection, + isPopoverVisible: true, + reportActionDraftMessage: draftMessage, + }, onShow); + }); + } + + /** + * This gets called on Dimensions change to find the anchor coordinates for the action context menu. + */ + measureContextMenuAnchorPosition() { + if (!this.state.isPopoverVisible) { + return; + } + this.getContextMenuMeasuredLocation().then(({x, y}) => { + if (!x || !y) { + return; + } + this.setState(prev => ({ + popoverAnchorPosition: { + horizontal: prev.cursorRelativePosition.horizontal + x, + vertical: prev.cursorRelativePosition.vertical + y, + }, + })); + }); + } + + /** + * After Popover hides, call the registered onPopoverHide callback and reset it + */ + runAndResetOnPopoverHide() { + this.onPopoverHide(); + + // After we have called the action, reset it. + this.onPopoverHide = () => {}; + } + + /** + * Hide the ReportActionContextMenu modal popover. + * @param {Function} onHideCallback Callback to be called after popover is completely hidden + */ + hideContextMenu(onHideCallback) { + if (_.isFunction(onHideCallback)) { + this.onPopoverHide = onHideCallback; + } + this.setState({ + reportID: 0, + reportAction: {}, + selection: '', + reportActionDraftMessage: '', + isPopoverVisible: false, + }); + } + + /** + * Used to calculate the Context Menu Dimensions + * + * @returns {JSX} + */ + measureContent() { + return ( + + ); + } + + confirmDeleteAndHideModal() { + deleteReportComment(this.state.reportID, this.state.reportAction); + this.setState({isDeleteCommentConfirmModalVisible: false}); + } + + hideDeleteModal() { + this.setState({ + reportID: 0, + reportAction: {}, + isDeleteCommentConfirmModalVisible: false, + }); + } + + /** + * Opens the Confirm delete action modal + * @param {Number} reportID + * @param {Object} reportAction + */ + showDeleteModal(reportID, reportAction) { + this.setState({reportID, reportAction, isDeleteCommentConfirmModalVisible: true}); + } + + render() { + return ( + <> + + + + + + ); + } +} + +PopoverReportActionContextMenu.propTypes = propTypes; +PopoverReportActionContextMenu.displayName = 'PopoverReportActionContextMenu'; + +export default withLocalize(PopoverReportActionContextMenu); diff --git a/src/pages/home/report/ContextMenu/ReportActionContextMenu.js b/src/pages/home/report/ContextMenu/ReportActionContextMenu.js new file mode 100644 index 000000000000..7f438d4b2852 --- /dev/null +++ b/src/pages/home/report/ContextMenu/ReportActionContextMenu.js @@ -0,0 +1,99 @@ +import React from 'react'; + +const contextMenuRef = React.createRef(); + +/** + * Show the ReportActionContextMenu modal popover. + * + * @param {Object} [event] - A press event. + * @param {string} [selection] - A copy text. + * @param {Element} contextMenuAnchor - popoverAnchor + * @param {Number} reportID - Active Report Id + * @param {Object} reportAction - ReportAction for ContextMenu + * @param {String} draftMessage - ReportAction Draftmessage + * @param {Function} [onShow=() => {}] - Run a callback when Menu is shown + * @param {Function} [onHide=() => {}] - Run a callback when Menu is hidden + */ +function showContextMenu( + event, + selection, + contextMenuAnchor, + reportID, + reportAction, + draftMessage, + onShow = () => {}, + onHide = () => {}, +) { + if (!contextMenuRef.current) { + return; + } + contextMenuRef.current.showContextMenu( + event, + selection, + contextMenuAnchor, + reportID, + reportAction, + draftMessage, + onShow, + onHide, + ); +} + +/** + * Hide the ReportActionContextMenu modal popover. + * Hides the popover menu with an optional delay + * @param {Boolean} shouldDelay - whether the menu should close after a delay + * @param {Function} [onHideCallback=() => {}] - Callback to be called after Context Menu is completely hidden + */ +function hideContextMenu(shouldDelay, onHideCallback = () => {}) { + if (!contextMenuRef.current) { + return; + } + if (!shouldDelay) { + contextMenuRef.current.hideContextMenu(onHideCallback); + + return; + } + setTimeout(() => contextMenuRef.current.hideContextMenu(onHideCallback), 800); +} + +function hideDeleteModal() { + if (!contextMenuRef.current) { + return; + } + contextMenuRef.current.hideDeleteModal(); +} + +/** + * Opens the Confirm delete action modal + * @param {Number} reportID + * @param {Object} reportAction + */ +function showDeleteModal(reportID, reportAction) { + if (!contextMenuRef.current) { + return; + } + contextMenuRef.current.showDeleteModal(reportID, reportAction); +} + +/** + * Whether Context Menu is active for the Report Action. + * + * @param {Number|String} actionID + * @return {Boolean} + */ +function isActiveReportAction(actionID) { + if (!contextMenuRef.current) { + return; + } + return contextMenuRef.current.isActiveReportAction(actionID); +} + +export { + contextMenuRef, + showContextMenu, + hideContextMenu, + isActiveReportAction, + showDeleteModal, + hideDeleteModal, +}; diff --git a/src/pages/home/report/ReportActionContextMenu.js b/src/pages/home/report/ReportActionContextMenu.js deleted file mode 100755 index f7e0b7f63d71..000000000000 --- a/src/pages/home/report/ReportActionContextMenu.js +++ /dev/null @@ -1,199 +0,0 @@ -import _ from 'underscore'; -import React from 'react'; -import {View} from 'react-native'; -import PropTypes from 'prop-types'; -import lodashGet from 'lodash/get'; -import Str from 'expensify-common/lib/str'; -import { - Clipboard as ClipboardIcon, LinkCopy, Mail, Pencil, Trashcan, Checkmark, -} from '../../../components/Icon/Expensicons'; -import getReportActionContextMenuStyles from '../../../styles/getReportActionContextMenuStyles'; -import { - setNewMarkerPosition, updateLastReadActionID, saveReportActionDraft, -} from '../../../libs/actions/Report'; -import ContextMenuItem from '../../../components/ContextMenuItem'; -import ReportActionPropTypes from './ReportActionPropTypes'; -import Clipboard from '../../../libs/Clipboard'; -import {isReportMessageAttachment, canEditReportAction, canDeleteReportAction} from '../../../libs/reportUtils'; -import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize'; -import ReportActionComposeFocusManager from '../../../libs/ReportActionComposeFocusManager'; - -const propTypes = { - /** The ID of the report this report action is attached to. */ - // eslint-disable-next-line react/no-unused-prop-types - reportID: PropTypes.number.isRequired, - - /** The report action this context menu is attached to. */ - reportAction: PropTypes.shape(ReportActionPropTypes).isRequired, - - /** If true, this component will be a small, row-oriented menu that displays icons but not text. - If false, this component will be a larger, column-oriented menu that displays icons alongside text in each row. */ - isMini: PropTypes.bool, - - /** Controls the visibility of this component. */ - isVisible: PropTypes.bool, - - /** The copy selection of text. */ - selection: PropTypes.string, - - /** Draft message - if this is set the comment is in 'edit' mode */ - draftMessage: PropTypes.string, - - /** Function to dismiss the popover containing this menu */ - hidePopover: PropTypes.func.isRequired, - - /** Function to show the delete Action confirmation modal */ - showDeleteConfirmModal: PropTypes.func.isRequired, - - ...withLocalizePropTypes, -}; - -const defaultProps = { - isMini: false, - isVisible: false, - selection: '', - draftMessage: '', -}; - -class ReportActionContextMenu extends React.Component { - constructor(props) { - super(props); - - this.getActionText = this.getActionText.bind(this); - this.hidePopover = this.hidePopover.bind(this); - - // A list of all the context actions in this menu. - this.contextActions = [ - // Copy to clipboard - { - text: this.props.translate('contextMenuItem.copyToClipboard'), - icon: ClipboardIcon, - successText: this.props.translate('contextMenuItem.copied'), - successIcon: Checkmark, - shouldShow: true, - - // If return value is true, we switch the `text` and `icon` on - // `ContextMenuItem` with `successText` and `successIcon` which will fallback to - // the `text` and `icon` - onPress: () => { - const message = _.last(lodashGet(this.props.reportAction, 'message', null)); - const html = lodashGet(message, 'html', ''); - const text = Str.htmlDecode(props.selection || lodashGet(message, 'text', '')); - const isAttachment = _.has(this.props.reportAction, 'isAttachment') - ? this.props.reportAction.isAttachment - : isReportMessageAttachment(text); - if (!isAttachment) { - Clipboard.setString(text); - } else { - Clipboard.setString(html); - } - this.hidePopover(true, ReportActionComposeFocusManager.focus); - }, - }, - - { - text: this.props.translate('reportActionContextMenu.copyLink'), - icon: LinkCopy, - shouldShow: false, - onPress: () => {}, - }, - - { - text: this.props.translate('reportActionContextMenu.markAsUnread'), - icon: Mail, - successIcon: Checkmark, - shouldShow: true, - onPress: () => { - updateLastReadActionID(this.props.reportID, this.props.reportAction.sequenceNumber); - setNewMarkerPosition(this.props.reportID, this.props.reportAction.sequenceNumber); - this.hidePopover(true, ReportActionComposeFocusManager.focus); - }, - }, - - { - text: this.props.translate('reportActionContextMenu.editComment'), - icon: Pencil, - shouldShow: () => canEditReportAction(this.props.reportAction), - onPress: () => { - const editAction = () => saveReportActionDraft( - this.props.reportID, - this.props.reportAction.reportActionID, - _.isEmpty(this.props.draftMessage) ? this.getActionText() : '', - ); - - if (this.props.isMini) { - // No popover to hide, call editAction immediately - editAction(); - } else { - // Hide popover, then call editAction - this.hidePopover(false, editAction); - } - }, - }, - { - text: this.props.translate('reportActionContextMenu.deleteComment'), - icon: Trashcan, - shouldShow: () => canDeleteReportAction(this.props.reportAction), - onPress: () => { - if (this.props.isMini) { - // No popover to hide, call showDeleteConfirmModal immediately - this.props.showDeleteConfirmModal(); - } else { - // Hide popover, then call showDeleteConfirmModal - this.hidePopover(false, this.props.showDeleteConfirmModal); - } - }, - }, - ]; - - this.wrapperStyle = getReportActionContextMenuStyles(this.props.isMini); - } - - /** - * Gets the markdown version of the message in an action. - * - * @return {String} - */ - getActionText() { - const message = _.last(lodashGet(this.props.reportAction, 'message', null)); - return lodashGet(message, 'html', ''); - } - - /** - * Hides the popover menu with an optional delay - * - * @param {Boolean} shouldDelay whether the menu should close after a delay - * @param {Function} [onHideCallback=() => {}] Callback to be called after Popover Menu is hidden - * @memberof ReportActionContextMenu - */ - hidePopover(shouldDelay, onHideCallback = () => {}) { - if (!shouldDelay) { - this.props.hidePopover(onHideCallback); - return; - } - setTimeout(() => this.props.hidePopover(onHideCallback), 800); - } - - render() { - return this.props.isVisible && ( - - {this.contextActions.map(contextAction => _.result(contextAction, 'shouldShow', false) && ( - contextAction.onPress(this.props.reportAction)} - /> - ))} - - ); - } -} - -ReportActionContextMenu.propTypes = propTypes; -ReportActionContextMenu.defaultProps = defaultProps; - -export default withLocalize(ReportActionContextMenu); diff --git a/src/pages/home/report/ReportActionItem.js b/src/pages/home/report/ReportActionItem.js index 64b3a9fa4929..010f9e299817 100644 --- a/src/pages/home/report/ReportActionItem.js +++ b/src/pages/home/report/ReportActionItem.js @@ -1,6 +1,6 @@ import _ from 'underscore'; import React, {Component} from 'react'; -import {Dimensions, View} from 'react-native'; +import {View} from 'react-native'; import PropTypes from 'prop-types'; import {withOnyx} from 'react-native-onyx'; import CONST from '../../../CONST'; @@ -8,25 +8,21 @@ import ONYXKEYS from '../../../ONYXKEYS'; import ReportActionPropTypes from './ReportActionPropTypes'; import { getReportActionItemStyle, - getMiniReportActionContextMenuWrapperStyle, } from '../../../styles/getReportActionItemStyles'; import PressableWithSecondaryInteraction from '../../../components/PressableWithSecondaryInteraction'; import Hoverable from '../../../components/Hoverable'; -import PopoverWithMeasuredContent from '../../../components/PopoverWithMeasuredContent'; import ReportActionItemSingle from './ReportActionItemSingle'; import ReportActionItemGrouped from './ReportActionItemGrouped'; -import ReportActionContextMenu from './ReportActionContextMenu'; import IOUAction from '../../../components/ReportActionItem/IOUAction'; import ReportActionItemMessage from './ReportActionItemMessage'; import UnreadActionIndicator from '../../../components/UnreadActionIndicator'; import ReportActionItemMessageEdit from './ReportActionItemMessageEdit'; -import ConfirmModal from '../../../components/ConfirmModal'; import compose from '../../../libs/compose'; -import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize'; -import {deleteReportComment} from '../../../libs/actions/Report'; import withWindowDimensions, {windowDimensionsPropTypes} from '../../../components/withWindowDimensions'; import ControlSelection from '../../../libs/ControlSelection'; import canUseTouchScreen from '../../../libs/canUseTouchscreen'; +import MiniReportActionContextMenu from './ContextMenu/MiniReportActionContextMenu'; +import {isActiveReportAction, showContextMenu} from './ContextMenu/ReportActionContextMenu'; const propTypes = { /** The ID of the report this action is on. */ @@ -50,12 +46,9 @@ const propTypes = { /** Position index of the report action in the overall report FlatList view */ index: PropTypes.number.isRequired, - /* Onyx Props */ - /** Draft message - if this is set the comment is in 'edit' mode */ draftMessage: PropTypes.string, - ...withLocalizePropTypes, ...windowDimensionsPropTypes, }; @@ -67,115 +60,22 @@ const defaultProps = { class ReportActionItem extends Component { constructor(props) { super(props); - - this.onPopoverHide = () => {}; + this.popoverAnchor = undefined; this.state = { - isPopoverVisible: false, - isDeleteCommentConfirmModalVisible: false, - cursorPosition: { - horizontal: 0, - vertical: 0, - }, - - // The horizontal and vertical position (relative to the screen) where the popover will display. - popoverAnchorPosition: { - horizontal: 0, - vertical: 0, - }, + isContextMenuActive: isActiveReportAction(props.action.reportActionID), }; - - this.popoverAnchor = undefined; + this.checkIfContextMenuActive = this.checkIfContextMenuActive.bind(this); this.showPopover = this.showPopover.bind(this); - this.hidePopover = this.hidePopover.bind(this); - this.measureContent = this.measureContent.bind(this); - this.selection = ''; - this.measureContextMenuAnchorPosition = this.measureContextMenuAnchorPosition.bind(this); - this.confirmDeleteAndHideModal = this.confirmDeleteAndHideModal.bind(this); - this.hideDeleteConfirmModal = this.hideDeleteConfirmModal.bind(this); - this.showDeleteConfirmModal = this.showDeleteConfirmModal.bind(this); - this.contextMenuHide = this.contextMenuHide.bind(this); - } - - componentDidMount() { - Dimensions.addEventListener('change', this.measureContextMenuAnchorPosition); } shouldComponentUpdate(nextProps, nextState) { - return this.state.isPopoverVisible !== nextState.isPopoverVisible - || this.state.popoverAnchorPosition !== nextState.popoverAnchorPosition - || this.state.isDeleteCommentConfirmModalVisible !== nextState.isDeleteCommentConfirmModalVisible - || this.props.displayAsGroup !== nextProps.displayAsGroup + return this.props.displayAsGroup !== nextProps.displayAsGroup || this.props.draftMessage !== nextProps.draftMessage || this.props.isMostRecentIOUReportAction !== nextProps.isMostRecentIOUReportAction || this.props.hasOutstandingIOU !== nextProps.hasOutstandingIOU || this.props.shouldDisplayNewIndicator !== nextProps.shouldDisplayNewIndicator - || !_.isEqual(this.props.action, nextProps.action); - } - - componentWillUnmount() { - Dimensions.removeEventListener('change', this.measureContextMenuAnchorPosition); - } - - /** - * Get the Context menu anchor position - * We calculate the achor coordinates from measureInWindow async method - * - * @returns {Promise} - * @memberof ReportActionItem - */ - getMeasureLocation() { - return new Promise((res) => { - if (this.popoverAnchor) { - this.popoverAnchor.measureInWindow((x, y) => res({x, y})); - } else { - res({x: 0, y: 0}); - } - }); - } - - /** - * Save the location of a native press event & set the Initial Context menu anchor coordinates - * - * @param {Object} nativeEvent - * @returns {Promise} - */ - capturePressLocation(nativeEvent) { - return this.getMeasureLocation().then(({x, y}) => { - this.setState({ - cursorPosition: { - horizontal: nativeEvent.pageX - x, - vertical: nativeEvent.pageY - y, - }, - popoverAnchorPosition: { - horizontal: nativeEvent.pageX, - vertical: nativeEvent.pageY, - }, - }); - }); - } - - contextMenuHide() { - this.onPopoverHide(); - - // After we have called the action, reset it. - this.onPopoverHide = () => {}; - } - - /** - * This gets called on Dimensions change to find the anchor coordinates for the action context menu. - */ - measureContextMenuAnchorPosition() { - if (!this.state.isPopoverVisible) { - return; - } - this.getMeasureLocation().then(({x, y}) => { - this.setState(prev => ({ - popoverAnchorPosition: { - horizontal: prev.cursorPosition.horizontal + x, - vertical: prev.cursorPosition.vertical + y, - }, - })); - }); + || !_.isEqual(this.props.action, nextProps.action) + || this.state.isContextMenuActive !== nextState.isContextMenuActive; } /** @@ -189,59 +89,20 @@ class ReportActionItem extends Component { if (this.props.draftMessage) { return; } - const nativeEvent = event.nativeEvent || {}; - this.selection = selection; - this.capturePressLocation(nativeEvent).then(() => { - this.setState({isPopoverVisible: true}); - }); - } - - /** - * Hide the ReportActionContextMenu modal popover. - * @param {Function} onHideCallback Callback to be called after popover is completely hidden - */ - hidePopover(onHideCallback) { - if (_.isFunction(onHideCallback)) { - this.onPopoverHide = onHideCallback; - } - this.setState({isPopoverVisible: false}); - } - - /** - * Used to calculate the Context Menu Dimensions - * - * @returns {JSX} - * @memberof ReportActionItem - */ - measureContent() { - return ( - + showContextMenu( + event, + selection, + this.popoverAnchor, + this.props.reportID, + this.props.action, + this.props.draftMessage, + this.checkIfContextMenuActive, + this.checkIfContextMenuActive, ); } - confirmDeleteAndHideModal() { - deleteReportComment(this.props.reportID, this.props.action); - this.setState({isDeleteCommentConfirmModalVisible: false}); - } - - hideDeleteConfirmModal() { - this.setState({isDeleteCommentConfirmModalVisible: false}); - } - - /** - * Opens the Confirm delete action modal - * - * @memberof ReportActionItem - */ - showDeleteConfirmModal() { - this.setState({isDeleteCommentConfirmModalVisible: true}); + checkIfContextMenuActive() { + this.setState({isContextMenuActive: isActiveReportAction(this.props.action.reportActionID)}); } render() { @@ -267,98 +128,63 @@ class ReportActionItem extends Component { ); } return ( - <> - this.popoverAnchor = el} - onPressIn={() => this.props.isSmallScreenWidth && canUseTouchScreen() && ControlSelection.block()} - onPressOut={() => ControlSelection.unblock()} - onSecondaryInteraction={this.showPopover} - preventDefaultContentMenu={!this.props.draftMessage} - > - - {hovered => ( - - {this.props.shouldDisplayNewIndicator && ( - + this.popoverAnchor = el} + onPressIn={() => this.props.isSmallScreenWidth && canUseTouchScreen() && ControlSelection.block()} + onPressOut={() => ControlSelection.unblock()} + onSecondaryInteraction={this.showPopover} + preventDefaultContentMenu={!this.props.draftMessage} + + > + + {hovered => ( + + {this.props.shouldDisplayNewIndicator && ( + + )} + + {!this.props.displayAsGroup + ? ( + + {children} + + ) + : ( + + {children} + )} - > - {!this.props.displayAsGroup - ? ( - - {children} - - ) - : ( - - {children} - - )} - - - - - )} - - - - - - - + + + )} + + ); } } - ReportActionItem.propTypes = propTypes; ReportActionItem.defaultProps = defaultProps; export default compose( withWindowDimensions, - withLocalize, withOnyx({ draftMessage: { key: ({ diff --git a/src/pages/home/report/ReportActionsView.js b/src/pages/home/report/ReportActionsView.js index f8bea1b7c2d8..7f5f73b20585 100755 --- a/src/pages/home/report/ReportActionsView.js +++ b/src/pages/home/report/ReportActionsView.js @@ -34,6 +34,8 @@ import withDrawerState, {withDrawerPropTypes} from '../../../components/withDraw import {flatListRef, scrollToBottom} from '../../../libs/ReportScrollManager'; import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize'; import ReportActionComposeFocusManager from '../../../libs/ReportActionComposeFocusManager'; +import {contextMenuRef} from './ContextMenu/ReportActionContextMenu'; +import PopoverReportActionContextMenu from './ContextMenu/PopoverReportActionContextMenu'; const propTypes = { /** The ID of the report actions will be created for */ @@ -100,6 +102,7 @@ class ReportActionsView extends React.Component { this.updateSortedReportActions(props.reportActions); this.updateMostRecentIOUReportActionNumber(props.reportActions); + this.keyExtractor = this.keyExtractor.bind(this); } componentDidMount() { @@ -216,6 +219,17 @@ class ReportActionsView extends React.Component { } } + /** + * Create a unique key for Each Action in the FlatList. + * We use a combination of sequenceNumber and clientID in case the clientID are the same - which + * shouldn't happen, but might be possible in some rare cases. + * @param {Object} item + * @return {String} + */ + keyExtractor(item) { + return `${item.action.sequenceNumber}${item.action.clientID}`; + } + /** * Retrieves the next set of report actions for the chat once we are nearing the end of what we are currently * displaying. @@ -365,7 +379,7 @@ class ReportActionsView extends React.Component { index, }) { const shouldDisplayNewIndicator = this.props.report.newMarkerSequenceNumber > 0 - && item.action.sequenceNumber === this.props.report.newMarkerSequenceNumber; + && item.action.sequenceNumber === this.props.report.newMarkerSequenceNumber; return ( `${item.action.sequenceNumber}${item.action.clientID}`} - initialRowHeight={32} - onEndReached={this.loadMoreChats} - onEndReachedThreshold={0.75} - ListFooterComponent={this.state.isLoadingMoreChats - ? - : null} - keyboardShouldPersistTaps="handled" - onLayout={this.recordTimeToMeasureItemLayout} - /> + <> + + : null} + keyboardShouldPersistTaps="handled" + onLayout={this.recordTimeToMeasureItemLayout} + /> + + ); } }