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