diff --git a/src/components/PressableWithSecondaryInteraction/index.js b/src/components/PressableWithSecondaryInteraction/index.js index 771373293525..1096ce474048 100644 --- a/src/components/PressableWithSecondaryInteraction/index.js +++ b/src/components/PressableWithSecondaryInteraction/index.js @@ -31,7 +31,7 @@ class PressableWithSecondaryInteraction extends Component { * @param {Event} e - the secondary interaction event */ executeSecondaryInteraction(e) { - if (DeviceCapabilities.hasHoverSupport()) { + if (DeviceCapabilities.hasHoverSupport() && !this.props.enableLongPressWithHover) { return; } if (this.props.withoutFocusOnSecondaryInteraction && this.pressableRef) { diff --git a/src/components/PressableWithSecondaryInteraction/pressableWithSecondaryInteractionPropTypes.js b/src/components/PressableWithSecondaryInteraction/pressableWithSecondaryInteractionPropTypes.js index 12d44ddd5d81..c83761e73c1d 100644 --- a/src/components/PressableWithSecondaryInteraction/pressableWithSecondaryInteractionPropTypes.js +++ b/src/components/PressableWithSecondaryInteraction/pressableWithSecondaryInteractionPropTypes.js @@ -53,6 +53,7 @@ const defaultProps = { preventDefaultContextMenu: true, inline: false, withoutFocusOnSecondaryInteraction: false, + enableLongPressWithHover: false, }; export {propTypes, defaultProps}; diff --git a/src/components/Reactions/EmojiReactionBubble.js b/src/components/Reactions/EmojiReactionBubble.js index f1b87d48de2a..dda149e90c21 100644 --- a/src/components/Reactions/EmojiReactionBubble.js +++ b/src/components/Reactions/EmojiReactionBubble.js @@ -1,14 +1,11 @@ import React from 'react'; import PropTypes from 'prop-types'; -import {Pressable} from 'react-native'; import styles from '../../styles/styles'; import Text from '../Text'; import * as StyleUtils from '../../styles/StyleUtils'; -import withCurrentUserPersonalDetails, { - withCurrentUserPersonalDetailsDefaultProps, - withCurrentUserPersonalDetailsPropTypes, -} from '../withCurrentUserPersonalDetails'; -import * as Report from '../../libs/actions/Report'; +import PressableWithSecondaryInteraction from '../PressableWithSecondaryInteraction'; +import withWindowDimensions, {windowDimensionsPropTypes} from '../withWindowDimensions'; +import {withCurrentUserPersonalDetailsDefaultProps} from '../withCurrentUserPersonalDetails'; const propTypes = { /** @@ -32,64 +29,66 @@ const propTypes = { */ count: PropTypes.number, - /** - * The account ids of the users who reacted. - */ - reactionUsers: PropTypes.arrayOf(PropTypes.string), - /** Whether it is for context menu so we can modify its style */ isContextMenu: PropTypes.bool, - ...withCurrentUserPersonalDetailsPropTypes, + /** + * Returns true if the current account has reacted to the report action (with the given skin tone). + */ + hasUserReacted: PropTypes.bool, + + ...windowDimensionsPropTypes, }; const defaultProps = { count: 0, onReactionListOpen: () => {}, - reactionUsers: [], isContextMenu: false, ...withCurrentUserPersonalDetailsDefaultProps, }; -const EmojiReactionBubble = (props) => { - const hasUserReacted = Report.hasAccountIDReacted(props.currentUserPersonalDetails.accountID, props.reactionUsers); - return ( - [ - styles.emojiReactionBubble, - StyleUtils.getEmojiReactionBubbleStyle(hovered || pressed, hasUserReacted, props.isContextMenu), - ]} - onPress={props.onPress} - onLongPress={props.onReactionListOpen} +const EmojiReactionBubble = props => ( + [ + styles.emojiReactionBubble, + StyleUtils.getEmojiReactionBubbleStyle(hovered || pressed, props.hasUserReacted, props.isContextMenu), + ]} + onPress={props.onPress} + onLongPress={props.onReactionListOpen} + onSecondaryInteraction={props.onReactionListOpen} + ref={props.forwardedRef} + enableLongPressWithHover={props.isSmallScreenWidth} - // Prevent text input blur when emoji reaction is clicked - onMouseDown={e => e.preventDefault()} + // Prevent text input blur when emoji reaction is clicked + onMouseDown={e => e.preventDefault()} + > + - - {props.emojiCodes.join('')} - - {props.count > 0 && ( + {props.emojiCodes.join('')} + + {props.count > 0 && ( {props.count} - )} - - ); -}; + )} + +); EmojiReactionBubble.propTypes = propTypes; EmojiReactionBubble.defaultProps = defaultProps; EmojiReactionBubble.displayName = 'EmojiReactionBubble'; -export default withCurrentUserPersonalDetails(EmojiReactionBubble); +export default withWindowDimensions(React.forwardRef((props, ref) => ( + /* eslint-disable-next-line react/jsx-props-no-spreading */ + +))); diff --git a/src/components/Reactions/ReportActionItemReactions.js b/src/components/Reactions/ReportActionItemReactions.js index 66198e3b7cf2..2eddd6d4f1a6 100644 --- a/src/components/Reactions/ReportActionItemReactions.js +++ b/src/components/Reactions/ReportActionItemReactions.js @@ -1,4 +1,4 @@ -import React from 'react'; +import React, {useRef} from 'react'; import _ from 'underscore'; import {View} from 'react-native'; import PropTypes from 'prop-types'; @@ -6,7 +6,11 @@ import styles from '../../styles/styles'; import EmojiReactionBubble from './EmojiReactionBubble'; import emojis from '../../../assets/emojis'; import AddReactionBubble from './AddReactionBubble'; +import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsDefaultProps, withCurrentUserPersonalDetailsPropTypes} from '../withCurrentUserPersonalDetails'; import getPreferredEmojiCode from './getPreferredEmojiCode'; +import * as PersonalDetailsUtils from '../../libs/PersonalDetailsUtils'; +import * as Report from '../../libs/actions/Report'; +import * as ReactionList from '../../pages/home/report/ReactionList/ReactionList'; import Tooltip from '../Tooltip'; import ReactionTooltipContent from './ReactionTooltipContent'; @@ -52,22 +56,46 @@ const propTypes = { * hence this function asks to toggle the reaction by emoji. */ toggleReaction: PropTypes.func.isRequired, + + ...withCurrentUserPersonalDetailsPropTypes, +}; + +const defaultProps = { + ...withCurrentUserPersonalDetailsDefaultProps, }; const ReportActionItemReactions = (props) => { + const popoverReactionListAnchor = useRef(null); const reactionsWithCount = _.filter(props.reactions, reaction => reaction.users.length > 0); return ( - + + {_.map(reactionsWithCount, (reaction) => { const reactionCount = reaction.users.length; const reactionUsers = _.map(reaction.users, sender => sender.accountID.toString()); const emoji = _.find(emojis, e => e.name === reaction.emoji); const emojiCodes = getUniqueEmojiCodes(emoji, reaction.users); + const hasUserReacted = Report.hasAccountIDReacted(props.currentUserPersonalDetails.accountID, reactionUsers); const onPress = () => { props.toggleReaction(emoji); }; + const onReactionListOpen = (event) => { + const users = PersonalDetailsUtils.getPersonalDetailsByIDs(reactionUsers); + ReactionList.showReactionList( + event, + popoverReactionListAnchor.current, + users, + reaction.emoji, + emojiCodes, + reactionCount, + hasUserReacted, + ); + }; return ( { key={reaction.emoji} > + ); })} {reactionsWithCount.length > 0 && } @@ -96,4 +128,6 @@ const ReportActionItemReactions = (props) => { ReportActionItemReactions.displayName = 'ReportActionItemReactions'; ReportActionItemReactions.propTypes = propTypes; -export default ReportActionItemReactions; +ReportActionItemReactions.defaultProps = defaultProps; +export default withCurrentUserPersonalDetails(ReportActionItemReactions); + diff --git a/src/pages/home/report/ReactionList/BaseReactionList.js b/src/pages/home/report/ReactionList/BaseReactionList.js new file mode 100755 index 000000000000..0fbf8d8fbb51 --- /dev/null +++ b/src/pages/home/report/ReactionList/BaseReactionList.js @@ -0,0 +1,117 @@ +/* eslint-disable rulesdir/onyx-props-must-have-default */ +import React from 'react'; +import {FlatList} from 'react-native'; +import PropTypes from 'prop-types'; +import Str from 'expensify-common/lib/str'; +import styles from '../../../../styles/styles'; +import HeaderReactionList from './HeaderReactionList'; +import * as ReportUtils from '../../../../libs/ReportUtils'; +import CONST from '../../../../CONST'; +import participantPropTypes from '../../../../components/participantPropTypes'; +import reactionPropTypes from './reactionPropTypes'; +import OptionRow from '../../../../components/OptionRow'; +import variables from '../../../../styles/variables'; +import withWindowDimensions from '../../../../components/withWindowDimensions'; + +const propTypes = { + + /** + * Array of personal detail objects + */ + users: PropTypes.arrayOf(participantPropTypes).isRequired, + + /** + * Returns true if the current account has reacted to the report action (with the given skin tone). + */ + hasUserReacted: PropTypes.bool, + + ...reactionPropTypes, +}; + +const defaultProps = { + hasUserReacted: false, +}; + +/** + * Given an emoji item object, render a component based on its type. + * Items with the code "SPACER" return nothing and are used to fill rows up to 8 + * so that the sticky headers function properly + * + * @param {Object} params + * @param {Object} params.item + * @return {React.Component} + */ +const renderItem = ({item}) => ( + +); + +/** + * Create a unique key for each action in the FlatList. + * @param {Object} item + * @param {Number} index + * @return {String} + */ +const keyExtractor = (item, index) => `${item.login}+${index}`; + +/** + * This function will be used with FlatList getItemLayout property for optimization purpose that allows skipping + * the measurement of dynamic content if we know the size (height or width) of items ahead of time. + * Generate and return an object with properties length(height of each individual row), + * offset(distance of the current row from the top of the FlatList), index(current row index) + * + * @param {*} _ FlatList item + * @param {Number} index row index + * @returns {Object} + */ +const getItemLayout = (_, index) => ({ + index, + length: variables.listItemHeightNormal, + offset: variables.listItemHeightNormal * index, +}); + +const BaseReactionList = (props) => { + if (!props.isVisible) { + return null; + } + return ( + <> + + + + ); +}; + +BaseReactionList.propTypes = propTypes; +BaseReactionList.defaultProps = defaultProps; +BaseReactionList.displayName = 'BaseReactionList'; + +export default withWindowDimensions(BaseReactionList); diff --git a/src/pages/home/report/ReactionList/HeaderReactionList.js b/src/pages/home/report/ReactionList/HeaderReactionList.js new file mode 100644 index 000000000000..b3551aff4f60 --- /dev/null +++ b/src/pages/home/report/ReactionList/HeaderReactionList.js @@ -0,0 +1,63 @@ +import React from 'react'; +import {View, TouchableOpacity} from 'react-native'; +import PropTypes from 'prop-types'; +import styles from '../../../../styles/styles'; +import withLocalize, {withLocalizePropTypes} from '../../../../components/withLocalize'; +import Text from '../../../../components/Text'; +import Icon from '../../../../components/Icon'; +import * as Expensicons from '../../../../components/Icon/Expensicons'; +import * as StyleUtils from '../../../../styles/StyleUtils'; +import reactionPropTypes from './reactionPropTypes'; +import compose from '../../../../libs/compose'; +import withWindowDimensions, {windowDimensionsPropTypes} from '../../../../components/withWindowDimensions'; + +const propTypes = { + ...reactionPropTypes, + ...withLocalizePropTypes, + ...windowDimensionsPropTypes, + + /** + * Returns true if the current account has reacted to the report action (with the given skin tone). + */ + hasUserReacted: PropTypes.bool, +}; + +const defaultProps = { + hasUserReacted: false, +}; + +const HeaderReactionList = props => ( + + + + + {props.emojiCodes.join('')} + + + {props.emojiCount} + + + {`:${props.emojiName}:`} + + + {props.isSmallScreenWidth && ( + + + + )} + +); + +HeaderReactionList.propTypes = propTypes; +HeaderReactionList.defaultProps = defaultProps; +HeaderReactionList.displayName = 'HeaderReactionList'; + +export default compose( + withWindowDimensions, + withLocalize, +)(HeaderReactionList); + diff --git a/src/pages/home/report/ReactionList/PopoverReactionList.js b/src/pages/home/report/ReactionList/PopoverReactionList.js new file mode 100644 index 000000000000..c1a827de1e61 --- /dev/null +++ b/src/pages/home/report/ReactionList/PopoverReactionList.js @@ -0,0 +1,212 @@ +import React from 'react'; +import {Dimensions} from 'react-native'; + +import lodashGet from 'lodash/get'; +import withLocalize, {withLocalizePropTypes} from '../../../../components/withLocalize'; +import PopoverWithMeasuredContent from '../../../../components/PopoverWithMeasuredContent'; + +import BaseReactionList from './BaseReactionList'; + +const propTypes = { + ...withLocalizePropTypes, +}; + +class PopoverReactionList extends React.Component { + constructor(props) { + super(props); + + this.state = { + isPopoverVisible: 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, + }, + users: [], + emojiCodes: [], + emojiName: '', + emojiCount: 0, + hasUserReacted: false, + }; + + this.onPopoverHideActionCallback = () => {}; + this.reactionListAnchor = undefined; + this.showReactionList = this.showReactionList.bind(this); + + this.hideReactionList = this.hideReactionList.bind(this); + this.measureContent = this.measureContent.bind(this); + this.measureReactionListPosition = this.measureReactionListPosition.bind(this); + this.getReactionListMeasuredLocation = this.getReactionListMeasuredLocation.bind(this); + + this.dimensionsEventListener = null; + + this.contentRef = React.createRef(); + } + + componentDidMount() { + this.dimensionsEventListener = Dimensions.addEventListener('change', this.measureReactionListPosition); + } + + shouldComponentUpdate(nextProps, nextState) { + const previousLocale = lodashGet(this.props, 'preferredLocale', 'en'); + const nextLocale = lodashGet(nextProps, 'preferredLocale', 'en'); + return this.state.isPopoverVisible !== nextState.isPopoverVisible + || this.state.popoverAnchorPosition !== nextState.popoverAnchorPosition + || previousLocale !== nextLocale; + } + + componentWillUnmount() { + if (!this.dimensionsEventListener) { + return; + } + this.dimensionsEventListener.remove(); + } + + /** + * Get the PopoverReactionList anchor position + * We calculate the achor coordinates from measureInWindow async method + * + * @returns {Promise} + */ + getReactionListMeasuredLocation() { + return new Promise((resolve) => { + if (this.reactionListAnchor) { + this.reactionListAnchor.measureInWindow((x, y) => resolve({x, y})); + } else { + resolve({x: 0, y: 0}); + } + }); + } + + /** + * Show the ReactionList modal popover. + * + * @param {Object} [event] - A press event. + * @param {Element} reactionListAnchor - reactionListAnchor + * @param {Array} users - Array of personal detail objects + * @param {String} emojiName - Name of emoji + * @param {Array} emojiCodes - The emoji codes to display in the bubble. + * @param {Number} emojiCount - Count of emoji + * @param {Boolean} hasUserReacted - whether the current user has reacted to this emoji + + */ + showReactionList( + event, + reactionListAnchor, + users, + emojiName, + emojiCodes, + emojiCount, + hasUserReacted, + ) { + const nativeEvent = event.nativeEvent || {}; + + this.reactionListAnchor = reactionListAnchor; + + this.getReactionListMeasuredLocation().then(({x, y}) => { + this.setState({ + cursorRelativePosition: { + horizontal: nativeEvent.pageX - x, + vertical: nativeEvent.pageY - y, + }, + popoverAnchorPosition: { + horizontal: nativeEvent.pageX, + vertical: nativeEvent.pageY, + }, + users, + emojiName, + emojiCodes, + emojiCount, + isPopoverVisible: true, + hasUserReacted, + }); + }); + } + + /** + * This gets called on Dimensions change to find the anchor coordinates for the action PopoverReactionList. + */ + measureReactionListPosition() { + if (!this.state.isPopoverVisible) { + return; + } + this.getReactionListMeasuredLocation().then(({x, y}) => { + if (!x || !y) { + return; + } + this.setState(prev => ({ + popoverAnchorPosition: { + horizontal: prev.cursorRelativePosition.horizontal + x, + vertical: prev.cursorRelativePosition.vertical + y, + }, + })); + }); + } + + /** + * Hide the ReactionList modal popover. + */ + hideReactionList() { + this.setState({ + isPopoverVisible: false, + }); + } + + /** + * Used to calculate the PopoverReactionList Dimensions + * + * @returns {JSX} + */ + measureContent() { + return ( + + ); + } + + render() { + return ( + <> + + + + + ); + } +} + +PopoverReactionList.propTypes = propTypes; + +export default withLocalize(PopoverReactionList); diff --git a/src/pages/home/report/ReactionList/ReactionList.js b/src/pages/home/report/ReactionList/ReactionList.js new file mode 100644 index 000000000000..f5a1e97d9b06 --- /dev/null +++ b/src/pages/home/report/ReactionList/ReactionList.js @@ -0,0 +1,52 @@ +import React from 'react'; + +const reactionListRef = React.createRef(); + +/** + * Show the ReactionList popover modal popover. + * + * @param {Object} [event] - a press event. + * @param {Element} reactionListPopoverAnchor - popoverAnchor + * @param {Array} users - array of users id + * @param {String} emojiName - the emoji codes to display near the bubble. + * @param {String} emojiCodes - the emoji codes to display in the bubble. + * @param {Boolean} hasUserReacted - show if user has reacted + */ +function showReactionList( + event, + reactionListPopoverAnchor, + users, + emojiName, + emojiCodes, + hasUserReacted, +) { + if (!reactionListRef.current) { + return; + } + reactionListRef.current.showReactionList( + event, + reactionListPopoverAnchor, + users, + emojiName, + emojiCodes, + hasUserReacted, + ); +} + +/** + * Hide the ReactionList popover. + * @param {Function} [onHideCallback=() => {}] - Callback to be called after popover is completely hidden + */ +function hideReactionList(onHideCallback = () => {}) { + if (!reactionListRef.current) { + return; + } + + reactionListRef.current.hideReactionList(onHideCallback); +} + +export { + reactionListRef, + showReactionList, + hideReactionList, +}; diff --git a/src/pages/home/report/ReactionList/reactionPropTypes.js b/src/pages/home/report/ReactionList/reactionPropTypes.js new file mode 100644 index 000000000000..c8605ac39b41 --- /dev/null +++ b/src/pages/home/report/ReactionList/reactionPropTypes.js @@ -0,0 +1,18 @@ +import PropTypes from 'prop-types'; + +const propTypes = { + /** Hide the ReactionList modal popover */ + onClose: PropTypes.func, + + /** The emoji codes */ + emojiCodes: PropTypes.arrayOf(PropTypes.string).isRequired, + + /** The name of the emoji */ + emojiName: PropTypes.string.isRequired, + + /** Count of the emoji */ + emojiCount: PropTypes.number.isRequired, + +}; + +export default propTypes; diff --git a/src/pages/home/report/ReportActionItem.js b/src/pages/home/report/ReportActionItem.js index a47ff6d4de4e..27d777c06755 100644 --- a/src/pages/home/report/ReportActionItem.js +++ b/src/pages/home/report/ReportActionItem.js @@ -230,7 +230,6 @@ class ReportActionItem extends Component { const reactions = _.get(this.props, ['action', 'message', 0, 'reactions'], []); const hasReactions = reactions.length > 0; - return ( <> {children} diff --git a/src/pages/home/report/ReportActionsView.js b/src/pages/home/report/ReportActionsView.js index 089585701033..d06075dc047f 100755 --- a/src/pages/home/report/ReportActionsView.js +++ b/src/pages/home/report/ReportActionsView.js @@ -21,6 +21,8 @@ import CopySelectionHelper from '../../../components/CopySelectionHelper'; import * as ReportActionsUtils from '../../../libs/ReportActionsUtils'; import * as ReportUtils from '../../../libs/ReportUtils'; import reportPropTypes from '../../reportPropTypes'; +import * as ReactionList from './ReactionList/ReactionList'; +import PopoverReactionList from './ReactionList/PopoverReactionList'; import getIsReportFullyVisible from '../../../libs/getIsReportFullyVisible'; const propTypes = { @@ -356,6 +358,7 @@ class ReportActionsView extends React.Component { loadMoreChats={this.loadMoreChats} newMarkerReportActionID={this.state.newMarkerReportActionID} /> + ); diff --git a/src/styles/styles.js b/src/styles/styles.js index 5f3a0a96b929..cd2cb5f9b0cf 100644 --- a/src/styles/styles.js +++ b/src/styles/styles.js @@ -3014,6 +3014,35 @@ const styles = { alignSelf: 'flex-start', }, + emojiReactionListHeader: { + marginTop: 8, + paddingBottom: 20, + borderBottomColor: themeColors.border, + borderBottomWidth: 1, + marginHorizontal: 20, + }, + emojiReactionListHeaderBubble: { + paddingVertical: 2, + paddingHorizontal: 8, + borderRadius: 28, + backgroundColor: themeColors.border, + alignItems: 'center', + justifyContent: 'center', + flexDirection: 'row', + alignSelf: 'flex-start', + marginRight: 4, + }, + reactionListItem: { + flexDirection: 'row', + paddingVertical: 12, + paddingHorizontal: 20, + }, + reactionListHeaderText: { + color: themeColors.textSupporting, + marginLeft: 8, + alignSelf: 'center', + }, + miniQuickEmojiReactionText: { fontSize: 15, lineHeight: 20, @@ -3052,6 +3081,15 @@ const styles = { justifyContent: 'space-between', }, + reactionListContainer: { + maxHeight: variables.listItemHeightNormal * 5.75, + ...spacing.pv2, + }, + + reactionListContainerFixedWidth: { + maxWidth: variables.popoverWidth, + }, + validateCodeDigits: { color: themeColors.text, fontFamily: fontFamily.EXP_NEUE, diff --git a/src/styles/variables.js b/src/styles/variables.js index 54b1707deefc..f6bc4b2fe16c 100644 --- a/src/styles/variables.js +++ b/src/styles/variables.js @@ -128,6 +128,8 @@ export default { signInLogoWidthPill: 132, signInLogoWidthLargeScreenPill: 162, modalContentMaxWidth: 360, + listItemHeightNormal: 64, + popoverWidth: 375, // The height of the empty list is 14px (2px for borders and 12px for vertical padding) // This is calculated based on the values specified in the 'getGoogleListViewStyle' function of the 'StyleUtils' utility