diff --git a/assets/images/add-reaction.svg b/assets/images/add-reaction.svg new file mode 100644 index 000000000000..a576e2c84622 --- /dev/null +++ b/assets/images/add-reaction.svg @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/src/CONST.js b/src/CONST.js index b8c26f163376..a9a12482a461 100755 --- a/src/CONST.js +++ b/src/CONST.js @@ -973,6 +973,32 @@ const CONST = { MAKE_REQUEST_WITH_SIDE_EFFECTS: 'makeRequestWithSideEffects', }, + QUICK_REACTIONS: [ + { + name: '+1', + code: '👍', + types: [ + '👍🏿', + '👍🏾', + '👍🏽', + '👍🏼', + '👍🏻', + ], + }, + { + name: 'heart', + code: '❤️', + }, + { + name: 'joy', + code: '😂', + }, + { + name: 'fire', + code: '🔥', + }, + ], + TFA_CODE_LENGTH: 6, CHAT_ATTACHMENT_TOKEN_KEY: 'X-Chat-Attachment-Token', diff --git a/src/components/BaseMiniContextMenuItem.js b/src/components/BaseMiniContextMenuItem.js new file mode 100644 index 000000000000..c91df470ba34 --- /dev/null +++ b/src/components/BaseMiniContextMenuItem.js @@ -0,0 +1,77 @@ +import {Pressable, View} from 'react-native'; +import React from 'react'; +import PropTypes from 'prop-types'; +import _ from 'underscore'; +import styles from '../styles/styles'; +import * as StyleUtils from '../styles/StyleUtils'; +import getButtonState from '../libs/getButtonState'; +import variables from '../styles/variables'; +import Tooltip from './Tooltip'; + +const propTypes = { + /** + * Text to display when hovering the menu item + */ + tooltipText: PropTypes.string.isRequired, + + /** + * Callback to fire on press + */ + onPress: PropTypes.func.isRequired, + + /** + * The children to display within the menu item + */ + children: PropTypes.oneOfType([PropTypes.func, PropTypes.node]).isRequired, + + /** + * Whether the button should be in the active state + */ + isDelayButtonStateComplete: PropTypes.bool, + + /** + * A ref to forward to the Pressable + */ + innerRef: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), +}; + +const defaultProps = { + isDelayButtonStateComplete: true, + innerRef: () => {}, +}; + +/** + * Component that renders a mini context menu item with a + * pressable. Also renders a tooltip when hovering the item. + * @param {Object} props + * @returns {JSX.Element} + */ +const BaseMiniContextMenuItem = props => ( + + [ + styles.reportActionContextMenuMiniButton, + StyleUtils.getButtonBackgroundColorStyle(getButtonState(hovered, pressed, props.isDelayButtonStateComplete)), + ] + } + > + {pressableState => ( + + {_.isFunction(props.children) ? props.children(pressableState) : props.children} + + )} + + +); + +BaseMiniContextMenuItem.propTypes = propTypes; +BaseMiniContextMenuItem.defaultProps = defaultProps; +BaseMiniContextMenuItem.displayName = 'BaseMiniContextMenuItem'; + +// eslint-disable-next-line react/jsx-props-no-spreading +export default React.forwardRef((props, ref) => ); diff --git a/src/components/ContextMenuItem.js b/src/components/ContextMenuItem.js index c87fc5e9b4c3..9dace75bec03 100644 --- a/src/components/ContextMenuItem.js +++ b/src/components/ContextMenuItem.js @@ -1,14 +1,12 @@ import React, {Component} from 'react'; import PropTypes from 'prop-types'; -import {Pressable, View} from 'react-native'; import MenuItem from './MenuItem'; -import Tooltip from './Tooltip'; import Icon from './Icon'; import styles from '../styles/styles'; import * as StyleUtils from '../styles/StyleUtils'; import getButtonState from '../libs/getButtonState'; import withDelayToggleButtonState, {withDelayToggleButtonStatePropTypes} from './withDelayToggleButtonState'; -import variables from '../styles/variables'; +import BaseMiniContextMenuItem from './BaseMiniContextMenuItem'; const propTypes = { /** Icon Component */ @@ -75,29 +73,19 @@ class ContextMenuItem extends Component { return ( this.props.isMini ? ( - - [ - styles.reportActionContextMenuMiniButton, - StyleUtils.getButtonBackgroundColorStyle(getButtonState(hovered, pressed, this.props.isDelayButtonStateComplete)), - ] - } - > - {({hovered, pressed}) => ( - - - - )} - - + + {({hovered, pressed}) => ( + + )} + ) : ( {}] - Run a callback when Modal hides. * @param {Function} [onEmojiSelected=() => {}] - Run a callback when Emoji selected. * @param {Element} emojiPopoverAnchor - Element to which Popover is anchored + * @param {Object} [anchorOrigin=DEFAULT_ANCHOR_ORIGIN] - Anchor origin for Popover + * @param {Function} [onWillShow=() => {}] - Run a callback when Popover will show */ - showEmojiPicker(onModalHide, onEmojiSelected, emojiPopoverAnchor) { + showEmojiPicker(onModalHide, onEmojiSelected, emojiPopoverAnchor, anchorOrigin, onWillShow = () => {}) { this.onModalHide = onModalHide; this.onEmojiSelected = onEmojiSelected; this.emojiPopoverAnchor = emojiPopoverAnchor; @@ -93,7 +103,8 @@ class EmojiPicker extends React.Component { } this.measureEmojiPopoverAnchorPosition().then((emojiPopoverAnchorPosition) => { - this.setState({isEmojiPickerVisible: true, emojiPopoverAnchorPosition}); + onWillShow(); + this.setState({isEmojiPickerVisible: true, emojiPopoverAnchorPosition, emojiPopoverAnchorOrigin: anchorOrigin || DEFAULT_ANCHOR_ORIGIN}); }); } @@ -157,10 +168,7 @@ class EmojiPicker extends React.Component { width: CONST.EMOJI_PICKER_SIZE.WIDTH, height: CONST.EMOJI_PICKER_SIZE.HEIGHT, }} - anchorOrigin={{ - horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT, - vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, - }} + anchorOrigin={this.state.emojiPopoverAnchorOrigin} measureContent={this.measureContent} > {}, + onPressOpenPicker: undefined, +}; + +const AddReactionBubble = (props) => { + const ref = React.createRef(); + + const onPress = () => { + const openPicker = (refParam, anchorOrigin) => { + EmojiPickerAction.showEmojiPicker( + () => {}, + (emojiCode, emojiObject) => { + props.onSelectEmoji(emojiObject); + }, + refParam || ref.current, + anchorOrigin, + props.onWillShowPicker, + ); + }; + + if (props.onPressOpenPicker) { + props.onPressOpenPicker(openPicker); + } else { + openPicker(); + } + }; + + return ( + + [ + styles.emojiReactionBubble, + StyleUtils.getEmojiReactionBubbleStyle(hovered || pressed, false, props.sizeScale), + ]} + onPress={onPress} + > + {({ + hovered, + pressed, + }) => ( + <> + {/* This (invisible) text will make the view have the same size as a regular + emoji reaction. We make the text invisible and put the + icon on top of it. */} + + {'\u2800\u2800'} + + + + + + )} + + + + ); +}; + +AddReactionBubble.propTypes = propTypes; +AddReactionBubble.defaultProps = defaultProps; +AddReactionBubble.displayName = 'AddReactionBubble'; + +export default withLocalize(AddReactionBubble); diff --git a/src/components/Reactions/EmojiReactionBubble.js b/src/components/Reactions/EmojiReactionBubble.js new file mode 100644 index 000000000000..d9a77e5f74f2 --- /dev/null +++ b/src/components/Reactions/EmojiReactionBubble.js @@ -0,0 +1,94 @@ +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'; + +const propTypes = { + /** + * The emoji codes to display in the bubble. + */ + emojiCodes: PropTypes.arrayOf(PropTypes.string).isRequired, + + /** + * Called when the user presses on the reaction bubble. + */ + onPress: PropTypes.func.isRequired, + + /** + * Called when the user long presses or right clicks + * on the reaction bubble. + */ + onReactionListOpen: PropTypes.func, + + /** + * The number of reactions to display in the bubble. + */ + count: PropTypes.number, + + /** + * The account ids of the users who reacted. + */ + reactionUsers: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.string, PropTypes.number])), + + /** + * The default size of the reaction bubble is defined + * by the styles in styles.js. This scale factor can be used + * to make the bubble bigger or smaller. + */ + sizeScale: PropTypes.number, + + ...withCurrentUserPersonalDetailsPropTypes, +}; + +const defaultProps = { + count: 0, + onReactionListOpen: () => {}, + reactionUsers: [], + sizeScale: 1, + + ...withCurrentUserPersonalDetailsDefaultProps, +}; + +const EmojiReactionBubble = (props) => { + const hasUserReacted = Report.hasAccountIDReacted(props.currentUserPersonalDetails.accountID, props.reactionUsers); + return ( + [ + styles.emojiReactionBubble, + StyleUtils.getEmojiReactionBubbleStyle(hovered, hasUserReacted, props.sizeScale), + ]} + onPress={props.onPress} + onLongPress={props.onReactionListOpen} + > + + {props.emojiCodes.join('')} + + {props.count > 0 && ( + + {props.count} + + )} + + ); +}; + +EmojiReactionBubble.propTypes = propTypes; +EmojiReactionBubble.defaultProps = defaultProps; +EmojiReactionBubble.displayName = 'EmojiReactionBubble'; + +export default withCurrentUserPersonalDetails(EmojiReactionBubble); diff --git a/src/components/Reactions/MiniQuickEmojiReactions.js b/src/components/Reactions/MiniQuickEmojiReactions.js new file mode 100644 index 000000000000..9cde8dc5eddf --- /dev/null +++ b/src/components/Reactions/MiniQuickEmojiReactions.js @@ -0,0 +1,111 @@ +import React from 'react'; +import {View} from 'react-native'; +import _ from 'underscore'; +import {withOnyx} from 'react-native-onyx'; +import PropTypes from 'prop-types'; +import CONST from '../../CONST'; +import styles from '../../styles/styles'; +import Text from '../Text'; +import * as StyleUtils from '../../styles/StyleUtils'; +import BaseMiniContextMenuItem from '../BaseMiniContextMenuItem'; +import Icon from '../Icon'; +import * as Expensicons from '../Icon/Expensicons'; +import getButtonState from '../../libs/getButtonState'; +import * as EmojiPickerAction from '../../libs/actions/EmojiPickerAction'; +import { + baseQuickEmojiReactionsPropTypes, +} from './QuickEmojiReactions/BaseQuickEmojiReactions'; +import withLocalize, {withLocalizePropTypes} from '../withLocalize'; +import compose from '../../libs/compose'; +import ONYXKEYS from '../../ONYXKEYS'; +import getPreferredEmojiCode from './getPreferredEmojiCode'; + +const ICON_SIZE_SCALE_FACTOR = 1.3; + +const propTypes = { + ...baseQuickEmojiReactionsPropTypes, + + /** + * Will be called when the user closed the emoji picker + * without selecting an emoji. + */ + onEmojiPickerClosed: PropTypes.func, + + ...withLocalizePropTypes, + preferredSkinTone: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired, + +}; + +const defaultProps = { + onEmojiPickerClosed: () => {}, +}; + +/** + * Shows the four common quick reactions and a + * emoji picker icon button. This is used for the mini + * context menu which we just show on web, when hovering + * a message. + * @param {Props} props + * @returns {JSX.Element} + */ +const MiniQuickEmojiReactions = (props) => { + const ref = React.createRef(); + + const openEmojiPicker = () => { + props.onPressOpenPicker(); + EmojiPickerAction.showEmojiPicker( + props.onEmojiPickerClosed, + (emojiCode, emojiObject) => { + props.onEmojiSelected(emojiObject); + }, + ref.current, + ); + }; + + return ( + + {_.map(CONST.QUICK_REACTIONS, emoji => ( + props.onEmojiSelected(emoji)} + > + + {getPreferredEmojiCode(emoji, props.preferredSkinTone)} + + + ))} + + {({hovered, pressed}) => ( + + )} + + + ); +}; + +MiniQuickEmojiReactions.displayName = 'MiniQuickEmojiReactions'; +MiniQuickEmojiReactions.propTypes = propTypes; +MiniQuickEmojiReactions.defaultProps = defaultProps; +export default compose( + withLocalize, + withOnyx({ + preferredSkinTone: { + key: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, + }, + }), +)(MiniQuickEmojiReactions); diff --git a/src/components/Reactions/QuickEmojiReactions/BaseQuickEmojiReactions.js b/src/components/Reactions/QuickEmojiReactions/BaseQuickEmojiReactions.js new file mode 100644 index 000000000000..6e561d4a5201 --- /dev/null +++ b/src/components/Reactions/QuickEmojiReactions/BaseQuickEmojiReactions.js @@ -0,0 +1,76 @@ +import React from 'react'; +import {View} from 'react-native'; +import _ from 'underscore'; +import PropTypes from 'prop-types'; +import {withOnyx} from 'react-native-onyx'; +import EmojiReactionBubble from '../EmojiReactionBubble'; +import AddReactionBubble from '../AddReactionBubble'; +import CONST from '../../../CONST'; +import styles from '../../../styles/styles'; +import ONYXKEYS from '../../../ONYXKEYS'; +import getPreferredEmojiCode from '../getPreferredEmojiCode'; +import Tooltip from '../../Tooltip'; + +const EMOJI_BUBBLE_SCALE = 1.5; + +const baseQuickEmojiReactionsPropTypes = { + /** + * Callback to fire when an emoji is selected. + */ + onEmojiSelected: PropTypes.func.isRequired, + + /** + * Will be called when the emoji picker is about to show. + */ + onWillShowPicker: PropTypes.func, + + /** + * Callback to fire when the "open emoji picker" button is pressed. + * The function receives an argument which can be called + * to actually open the emoji picker. + */ + onPressOpenPicker: PropTypes.func, +}; + +const propTypes = { + ...baseQuickEmojiReactionsPropTypes, + preferredSkinTone: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired, +}; + +const BaseQuickEmojiReactions = props => ( + + {_.map(CONST.QUICK_REACTIONS, emoji => ( + + // Note: focus is handled by the Pressable component in EmojiReactionBubble + + { + props.onEmojiSelected(emoji); + }} + /> + + ))} + + +); + +BaseQuickEmojiReactions.displayName = 'BaseQuickEmojiReactions'; +BaseQuickEmojiReactions.propTypes = propTypes; +export default withOnyx({ + preferredSkinTone: { + key: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, + }, +})(BaseQuickEmojiReactions); + +export { + baseQuickEmojiReactionsPropTypes, +}; diff --git a/src/components/Reactions/QuickEmojiReactions/index.js b/src/components/Reactions/QuickEmojiReactions/index.js new file mode 100644 index 000000000000..fbeb0b5867d3 --- /dev/null +++ b/src/components/Reactions/QuickEmojiReactions/index.js @@ -0,0 +1,38 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import BaseQuickEmojiReactions, {baseQuickEmojiReactionsPropTypes} from './BaseQuickEmojiReactions'; +import {contextMenuRef} from '../../../pages/home/report/ContextMenu/ReportActionContextMenu'; +import CONST from '../../../CONST'; + +const propTypes = { + ...baseQuickEmojiReactionsPropTypes, + + /** + * Function that can be called to close the + * context menu in which this component is + * rendered. + */ + closeContextMenu: PropTypes.func.isRequired, +}; + +const QuickEmojiReactions = (props) => { + const onPressOpenPicker = (openPicker) => { + openPicker(contextMenuRef.current.contentRef.current, { + horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT, + vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP, + }); + }; + + return ( + + ); +}; + +QuickEmojiReactions.displayName = 'QuickEmojiReactions'; +QuickEmojiReactions.propTypes = propTypes; +export default QuickEmojiReactions; diff --git a/src/components/Reactions/QuickEmojiReactions/index.native.js b/src/components/Reactions/QuickEmojiReactions/index.native.js new file mode 100644 index 000000000000..b7a9316b7252 --- /dev/null +++ b/src/components/Reactions/QuickEmojiReactions/index.native.js @@ -0,0 +1,38 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import BaseQuickEmojiReactions, {baseQuickEmojiReactionsPropTypes} from './BaseQuickEmojiReactions'; +import ReportActionComposeFocusManager from '../../../libs/ReportActionComposeFocusManager'; + +const propTypes = { + ...baseQuickEmojiReactionsPropTypes, + + /** + * Function that can be called to close the + * context menu in which this component is + * rendered. + */ + closeContextMenu: PropTypes.func.isRequired, +}; + +const QuickEmojiReactions = (props) => { + const onPressOpenPicker = (openPicker) => { + // We first need to close the menu as it's a popover. + // The picker is a popover as well and on mobile there can only + // be one active popover at a time. + props.closeContextMenu(() => { + // As the menu which includes the button to open the emoji picker + // gets closed, before the picker actually opens, we pass the composer + // ref as anchor for the emoji picker popover. + openPicker(ReportActionComposeFocusManager.composerRef.current); + }); + }; + + return ( + // eslint-disable-next-line react/jsx-props-no-spreading + + ); +}; + +QuickEmojiReactions.displayName = 'QuickEmojiReactions'; +QuickEmojiReactions.propTypes = propTypes; +export default QuickEmojiReactions; diff --git a/src/components/Reactions/ReportActionItemReactions.js b/src/components/Reactions/ReportActionItemReactions.js new file mode 100644 index 000000000000..bfc67e95c1df --- /dev/null +++ b/src/components/Reactions/ReportActionItemReactions.js @@ -0,0 +1,92 @@ +import React from 'react'; +import _ from 'underscore'; +import {View} from 'react-native'; +import PropTypes from 'prop-types'; +import styles from '../../styles/styles'; +import EmojiReactionBubble from './EmojiReactionBubble'; +import emojis from '../../../assets/emojis'; +import AddReactionBubble from './AddReactionBubble'; +import getPreferredEmojiCode from './getPreferredEmojiCode'; + +/** + * Given an emoji object and a list of senders it will return an + * array of emoji codes, that represents all used variations of the + * emoji. + * @param {{ name: string, code: string, types: string[] }} emoji + * @param {Array} users + * @return {string[]} + * */ +const getUniqueEmojiCodes = (emoji, users) => { + const emojiCodes = []; + _.forEach(users, (user) => { + const emojiCode = getPreferredEmojiCode(emoji, user.skinTone); + + if (emojiCode && !emojiCodes.includes(emojiCode)) { + emojiCodes.push(emojiCode); + } + }); + return emojiCodes; +}; + +const propTypes = { + /** + * An array of objects containing the reaction data. + * The shape of a reaction looks like this: + * + * "reactionName": { + * emoji: string, + * users: { + * accountID: string, + * skinTone: number, + * }[] + * } + */ + // eslint-disable-next-line react/forbid-prop-types + reactions: PropTypes.arrayOf(PropTypes.object).isRequired, + + /** + * Function to call when the user presses on an emoji. + * This can also be an emoji the user already reacted with, + * hence this function asks to toggle the reaction by emoji. + */ + toggleReaction: PropTypes.func.isRequired, +}; + +const ReportActionItemReactions = (props) => { + const reactionsWithCount = _.filter(props.reactions, reaction => reaction.users.length > 0); + + return ( + + {_.map(reactionsWithCount, (reaction) => { + const reactionCount = reaction.users.length; + if (reactionCount === 0) { + return null; + } + + const reactionUsers = _.map(reaction.users, sender => sender.accountID); + const emoji = _.find(emojis, e => e.name === reaction.emoji); + const emojiCodes = getUniqueEmojiCodes(emoji, reaction.users); + + const onPress = () => { + props.toggleReaction(emoji); + }; + + return ( + + ); + })} + {reactionsWithCount.length > 0 && } + + ); +}; + +ReportActionItemReactions.displayName = 'ReportActionItemReactions'; +ReportActionItemReactions.propTypes = propTypes; +export default ReportActionItemReactions; diff --git a/src/components/Reactions/getPreferredEmojiCode.js b/src/components/Reactions/getPreferredEmojiCode.js new file mode 100644 index 000000000000..00c3184b59dc --- /dev/null +++ b/src/components/Reactions/getPreferredEmojiCode.js @@ -0,0 +1,20 @@ +/** + * Given an emoji object it returns the correct emoji code + * based on the users preferred skin tone. + * @param {Object} emoji + * @param {String | Number} preferredSkinTone + * @returns {String} + */ +export default function getPreferredEmojiCode(emoji, preferredSkinTone) { + if (emoji.types) { + const emojiCodeWithSkinTone = emoji.types[preferredSkinTone]; + + // Note: it can happen that preferredSkinTone has a outdated format, + // so it makes sense to check if we actually got a valid emoji code back + if (emojiCodeWithSkinTone) { + return emojiCodeWithSkinTone; + } + } + + return emoji.code; +} diff --git a/src/components/Tooltip/index.js b/src/components/Tooltip/index.js index 68dcd2e1cce9..104098b375f0 100644 --- a/src/components/Tooltip/index.js +++ b/src/components/Tooltip/index.js @@ -150,9 +150,9 @@ class Tooltip extends PureComponent { let child = ( this.wrapperView = el} - style={this.props.containerStyles} onBlur={this.hideTooltip} - focusable + focusable={this.props.focusable} + style={this.props.containerStyles} > {this.props.children} diff --git a/src/components/Tooltip/tooltipPropTypes.js b/src/components/Tooltip/tooltipPropTypes.js index 3a8746b41dc0..014bdc3354b2 100644 --- a/src/components/Tooltip/tooltipPropTypes.js +++ b/src/components/Tooltip/tooltipPropTypes.js @@ -33,6 +33,9 @@ const propTypes = { /** Number of pixels to set max-width on tooltip */ maxWidth: PropTypes.number, + /** Accessibility prop. Sets the tabindex to 0 if true. Default is true. */ + focusable: PropTypes.bool, + /** Render custom content inside the tooltip. Note: This cannot be used together with the text props. */ renderTooltipContent: PropTypes.func, }; @@ -46,6 +49,7 @@ const defaultProps = { maxWidth: variables.sideBarWidth, numberOfLines: CONST.TOOLTIP_MAX_LINES, renderTooltipContent: undefined, + focusable: true, }; export { diff --git a/src/languages/en.js b/src/languages/en.js index 12cd89382c15..6e3f679d0284 100755 --- a/src/languages/en.js +++ b/src/languages/en.js @@ -226,6 +226,8 @@ export default { editComment: 'Edit comment', deleteComment: 'Delete comment', deleteConfirmation: 'Are you sure you want to delete this comment?', + addEmojiReaction: 'Add emoji reaction', + addReactionTooltip: 'Add Reaction…', }, reportActionsView: { beginningOfArchivedRoomPartOne: 'You missed the party in ', diff --git a/src/languages/es.js b/src/languages/es.js index e4ef7402cbbe..66945361554c 100644 --- a/src/languages/es.js +++ b/src/languages/es.js @@ -226,6 +226,8 @@ export default { editComment: 'Editar commentario', deleteComment: 'Eliminar comentario', deleteConfirmation: '¿Estás seguro de que quieres eliminar este comentario?', + addEmojiReaction: 'Añadir una reacción emoji', + addReactionTooltip: 'Añadir una reacción…', }, reportActionsView: { beginningOfArchivedRoomPartOne: 'Te perdiste la fiesta en ', diff --git a/src/libs/actions/EmojiPickerAction.js b/src/libs/actions/EmojiPickerAction.js index 2a355b65580c..2c5bba9aa626 100644 --- a/src/libs/actions/EmojiPickerAction.js +++ b/src/libs/actions/EmojiPickerAction.js @@ -8,11 +8,15 @@ const emojiPickerRef = React.createRef(); * @param {Function} [onModalHide=() => {}] - Run a callback when Modal hides. * @param {Function} [onEmojiSelected=() => {}] - Run a callback when Emoji selected. * @param {Element} emojiPopoverAnchor - Element on which EmojiPicker is anchored + * @param {Object} [anchorOrigin] - Anchor origin for Popover + * @param {Function} [onWillShow=() => {}] - Run a callback when Popover will show */ function showEmojiPicker( onModalHide = () => {}, onEmojiSelected = () => {}, emojiPopoverAnchor, + anchorOrigin = undefined, + onWillShow = () => {}, ) { if (!emojiPickerRef.current) { return; @@ -22,6 +26,8 @@ function showEmojiPicker( onModalHide, onEmojiSelected, emojiPopoverAnchor, + anchorOrigin, + onWillShow, ); } diff --git a/src/libs/actions/Report.js b/src/libs/actions/Report.js index 8b25233342d4..afd6692d7d40 100644 --- a/src/libs/actions/Report.js +++ b/src/libs/actions/Report.js @@ -36,6 +36,20 @@ Onyx.connect({ }, }); +let preferredSkinTone; +Onyx.connect({ + key: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, + callback: (val) => { + // the preferred skin tone is sometimes still "default", although it + // was changed that "default" has become -1. + if (Number.isInteger(Number(val))) { + preferredSkinTone = val; + } else { + preferredSkinTone = -1; + } + }, +}); + const allReports = {}; let conciergeChatReportID; const typingWatchTimers = {}; @@ -855,14 +869,15 @@ function editReportComment(reportID, originalReportAction, textForNewComment) { // Optimistically update the reportAction with the new message const reportActionID = originalReportAction.reportActionID; + const originalMessage = lodashGet(originalReportAction, ['message', 0]); const optimisticReportActions = { [reportActionID]: { pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, message: [{ + ...originalMessage, isEdited: true, html: htmlForNewComment, text: markdownForNewComment, - type: originalReportAction.message[0].type, }], }, }; @@ -1186,6 +1201,171 @@ function clearIOUError(reportID) { Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {errorFields: {iou: null}}); } +/** + * Internal function to help with updating the onyx state of a message of a report action. + * @param {Object} originalReportAction + * @param {Object} message + * @param {String} reportID + * @return {Object[]} + */ +function getOptimisticDataForReportActionUpdate(originalReportAction, message, reportID) { + const reportActionID = originalReportAction.reportActionID; + + return [ + { + onyxMethod: CONST.ONYX.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, + value: { + [reportActionID]: { + message: [message], + }, + }, + }, + ]; +} + +/** + * Returns true if the accountID has reacted to the report action (with the given skin tone). + * @param {String} accountID + * @param {Array} users + * @param {Number} [skinTone] + * @returns {boolean} + */ +function hasAccountIDReacted(accountID, users, skinTone) { + return _.find(users, (user) => { + let userAccountID; + if (typeof user === 'object') { + userAccountID = `${user.accountID}`; + } else { + userAccountID = `${user}`; + } + + return userAccountID === `${accountID}` && (skinTone == null ? true : user.skinTone === skinTone); + }) !== undefined; +} + +/** + * Adds a reaction to the report action. + * @param {String} reportID + * @param {Object} originalReportAction + * @param {{ name: string, code: string, types: string[] }} emoji + * @param {number} [skinTone] Optional. + */ +function addEmojiReaction(reportID, originalReportAction, emoji, skinTone = preferredSkinTone) { + const message = originalReportAction.message[0]; + let reactionObject = message.reactions && _.find(message.reactions, reaction => reaction.emoji === emoji.name); + const needToInsertReactionObject = !reactionObject; + if (needToInsertReactionObject) { + reactionObject = { + emoji: emoji.name, + users: [], + }; + } else { + // Make a copy of the reaction object so that we can modify it without mutating the original + reactionObject = {...reactionObject}; + } + + if (hasAccountIDReacted(currentUserAccountID, reactionObject.users, skinTone)) { + return; + } + + reactionObject.users = [...reactionObject.users, {accountID: currentUserAccountID, skinTone}]; + let updatedReactions = [...(message.reactions || [])]; + if (needToInsertReactionObject) { + updatedReactions = [...updatedReactions, reactionObject]; + } else { + updatedReactions = _.map(updatedReactions, reaction => (reaction.emoji === emoji.name ? reactionObject : reaction)); + } + + const updatedMessage = { + ...message, + reactions: updatedReactions, + }; + + // Optimistically update the reportAction with the reaction + const optimisticData = getOptimisticDataForReportActionUpdate(originalReportAction, updatedMessage, reportID); + + const parameters = { + reportID, + skinTone, + emojiCode: emoji.name, + sequenceNumber: originalReportAction.sequenceNumber, + reportActionID: originalReportAction.reportActionID, + }; + API.write('AddEmojiReaction', parameters, {optimisticData}); +} + +/** + * Removes a reaction to the report action. + * @param {String} reportID + * @param {Object} originalReportAction + * @param {{ name: string, code: string, types: string[] }} emoji + */ +function removeEmojiReaction(reportID, originalReportAction, emoji) { + const message = originalReportAction.message[0]; + const reactionObject = message.reactions && _.find(message.reactions, reaction => reaction.emoji === emoji.name); + if (!reactionObject) { + return; + } + + const updatedReactionObject = { + ...reactionObject, + }; + updatedReactionObject.users = _.filter(reactionObject.users, sender => sender.accountID !== currentUserAccountID); + const updatedReactions = _.filter( + + // Replace the reaction object either with the updated one or null if there are no users + _.map(message.reactions, (reaction) => { + if (reaction.emoji === emoji.name) { + if (updatedReactionObject.users.length === 0) { + return null; + } + return updatedReactionObject; + } + return reaction; + }), + + // Remove any null reactions + reportObject => reportObject !== null, + ); + + const updatedMessage = { + ...message, + reactions: updatedReactions, + }; + + // Optimistically update the reportAction with the reaction + const optimisticData = getOptimisticDataForReportActionUpdate(originalReportAction, updatedMessage, reportID); + + const parameters = { + reportID, + sequenceNumber: originalReportAction.sequenceNumber, + reportActionID: originalReportAction.reportActionID, + emojiCode: emoji.name, + }; + API.write('RemoveEmojiReaction', parameters, {optimisticData}); +} + +/** + * Calls either addEmojiReaction or removeEmojiReaction depending on if the current user has reacted to the report action. + * @param {String} reportID + * @param {Object} reportAction + * @param {Object} emoji + * @param {number} paramSkinTone + * @returns {Promise} + */ +function toggleEmojiReaction(reportID, reportAction, emoji, paramSkinTone = preferredSkinTone) { + const message = reportAction.message[0]; + const reactionObject = message.reactions && _.find(message.reactions, reaction => reaction.emoji === emoji.name); + const skinTone = emoji.types === undefined ? null : paramSkinTone; // only use skin tone if emoji supports it + if (reactionObject) { + if (hasAccountIDReacted(currentUserAccountID, reactionObject.users, skinTone)) { + return removeEmojiReaction(reportID, reportAction, emoji, skinTone); + } + } + return addEmojiReaction(reportID, reportAction, emoji, skinTone); +} + export { addComment, addAttachment, @@ -1217,4 +1397,8 @@ export { clearIOUError, subscribeToNewActionEvent, showReportActionNotification, + addEmojiReaction, + removeEmojiReaction, + toggleEmojiReaction, + hasAccountIDReacted, }; diff --git a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.js b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.js index 7a18ddc1ce73..e562a417d423 100755 --- a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.js +++ b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.js @@ -27,6 +27,8 @@ const propTypes = { /** Whether the provided report is an archived room */ isArchivedRoom: PropTypes.bool, + contentRef: PropTypes.oneOfType([PropTypes.node, PropTypes.object, PropTypes.func]), + ...genericReportActionContextMenuPropTypes, ...withLocalizePropTypes, ...windowDimensionsPropTypes, @@ -35,6 +37,7 @@ const propTypes = { const defaultProps = { type: CONTEXT_MENU_TYPES.REPORT_ACTION, anchor: null, + contentRef: null, isChronosReport: false, isArchivedRoom: false, ...GenericReportActionContextMenuDefaultProps, @@ -43,6 +46,10 @@ class BaseReportActionContextMenu extends React.Component { constructor(props) { super(props); this.wrapperStyle = getReportActionContextMenuStyles(this.props.isMini); + + this.state = { + shouldKeepOpen: false, + }; } render() { @@ -55,28 +62,47 @@ class BaseReportActionContextMenu extends React.Component { this.props.isChronosReport, ); - return this.props.isVisible && ( - - {_.map(_.filter(ContextMenuActions, shouldShowFilter), contextAction => ( - contextAction.onPress(!this.props.isMini, { - reportAction: this.props.reportAction, - reportID: this.props.reportID, - draftMessage: this.props.draftMessage, - selection: this.props.selection, - })} - description={contextAction.getDescription(this.props.selection, this.props.isSmallScreenWidth)} - autoReset={contextAction.autoReset} - /> - ))} + return (this.props.isVisible || this.state.shouldKeepOpen) && ( + + {_.map(_.filter(ContextMenuActions, shouldShowFilter), (contextAction) => { + const closePopup = !this.props.isMini; + const payload = { + reportAction: this.props.reportAction, + reportID: this.props.reportID, + draftMessage: this.props.draftMessage, + selection: this.props.selection, + close: () => this.setState({shouldKeepOpen: false}), + openContextMenu: () => this.setState({shouldKeepOpen: true}), + }; + + if (contextAction.renderContent) { + // make sure that renderContent isn't mixed with unsupported props + if (__DEV__ && (contextAction.text != null || contextAction.icon != null)) { + throw new Error('Dev error: renderContent() and text/icon cannot be used together.'); + } + + return contextAction.renderContent(closePopup, payload); + } + + return ( + contextAction.onPress(closePopup, payload)} + description={contextAction.getDescription(this.props.selection, this.props.isSmallScreenWidth)} + autoReset={contextAction.autoReset} + /> + ); + })} ); } diff --git a/src/pages/home/report/ContextMenu/ContextMenuActions.js b/src/pages/home/report/ContextMenu/ContextMenuActions.js index 490c2153399f..ddf803050dd3 100644 --- a/src/pages/home/report/ContextMenu/ContextMenuActions.js +++ b/src/pages/home/report/ContextMenu/ContextMenuActions.js @@ -1,3 +1,4 @@ +import React from 'react'; import _ from 'underscore'; import ExpensiMark from 'expensify-common/lib/ExpensiMark'; import Str from 'expensify-common/lib/str'; @@ -16,6 +17,8 @@ import addEncryptedAuthTokenToURL from '../../../../libs/addEncryptedAuthTokenTo import * as ContextMenuUtils from './ContextMenuUtils'; import * as Environment from '../../../../libs/Environment/Environment'; import Permissions from '../../../../libs/Permissions'; +import QuickEmojiReactions from '../../../../components/Reactions/QuickEmojiReactions'; +import MiniQuickEmojiReactions from '../../../../components/Reactions/MiniQuickEmojiReactions'; /** * Gets the HTML version of the message in an action. @@ -35,6 +38,50 @@ const CONTEXT_MENU_TYPES = { // A list of all the context actions in this menu. export default [ + { + shouldKeepOpen: true, + shouldShow: (type, reportAction) => reportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.IOU, + renderContent: (closePopover, { + reportID, reportAction, close: closeManually, openContextMenu, + }) => { + const isMini = !closePopover; + + const closeContextMenu = (onHideCallback) => { + if (isMini) { + closeManually(); + if (onHideCallback) { + onHideCallback(); + } + } else { + hideContextMenu(false, onHideCallback); + } + }; + + const onEmojiSelected = (emoji) => { + Report.toggleEmojiReaction(reportID, reportAction, emoji); + closeContextMenu(); + }; + + if (isMini) { + return ( + + ); + } + + return ( + + ); + }, + }, { textTranslateKey: 'common.download', icon: Expensicons.Download, diff --git a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js index dd637193b0e9..0075df0cdd31 100644 --- a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js +++ b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js @@ -56,6 +56,12 @@ class PopoverReportActionContextMenu extends React.Component { this.isActiveReportAction = this.isActiveReportAction.bind(this); this.dimensionsEventListener = null; + + this.contentRef = React.createRef(); + this.setContentRef = (ref) => { + this.contentRef.current = ref; + }; + this.setContentRef = this.setContentRef.bind(this); } componentDidMount() { @@ -238,6 +244,7 @@ class PopoverReportActionContextMenu extends React.Component { isArchivedRoom={this.state.isArchivedRoom} isChronosReport={this.state.isChronosReport} anchor={this.contextMenuTargetNode} + contentRef={this.setContentRef} /> ); } @@ -314,6 +321,7 @@ class PopoverReportActionContextMenu extends React.Component { isArchivedRoom={this.state.isArchivedRoom} isChronosReport={this.state.isChronosReport} anchor={this.contextMenuTargetNode} + contentRef={this.contentRef} /> ); } - return children; + + const reactions = _.get(this.props, ['action', 'message', 0, 'reactions'], []); + const hasReactions = reactions.length > 0; + + return ( + <> + {children} + {hasReactions && ( + + )} + + ); } render() { @@ -203,6 +224,7 @@ class ReportActionItem extends Component { if (this.props.action.actionName === CONST.REPORT.ACTIONS.TYPE.RENAMED) { return ; } + return ( { expect(Report.showReportActionNotification).toBeCalledWith(REPORT_ID, REPORT_ACTION); }); }); + + it('should properly toggle reactions on a message', () => { + global.fetch = TestHelper.getGlobalFetchMock(); + + const TEST_USER_ACCOUNT_ID = 1; + const TEST_USER_LOGIN = 'test@test.com'; + const REPORT_ID = 1; + const EMOJI_CODE = '👍'; + const EMOJI_SKIN_TONE = 2; + const EMOJI_NAME = '+1'; + const EMOJI = { + code: EMOJI_CODE, + name: EMOJI_NAME, + }; + + let reportActions; + + Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, + callback: val => reportActions = val, + }); + + // Set up Onyx with some test user data + return TestHelper.signInWithTestUser(TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN) + .then(() => { + User.subscribeToUserEvents(); + return waitForPromisesToResolve(); + }) + .then(() => TestHelper.setPersonalDetails(TEST_USER_LOGIN, TEST_USER_ACCOUNT_ID)) + .then(() => { + // This is a fire and forget response, but once it completes we should be able to verify that we + // have an "optimistic" report action in Onyx. + Report.addComment(REPORT_ID, 'Testing a comment'); + return waitForPromisesToResolve(); + }) + .then(() => { + const resultAction = _.first(_.values(reportActions)); + + // Add a reaction to the comment + Report.addEmojiReaction(REPORT_ID, resultAction, EMOJI); + return waitForPromisesToResolve(); + }) + .then(() => { + const resultAction = _.first(_.values(reportActions)); + + // Expect to have the reaction on the message + expect(resultAction.message[0].reactions) + .toEqual(expect.arrayContaining([ + expect.objectContaining({ + emoji: EMOJI_NAME, + users: expect.arrayContaining([ + expect.objectContaining({accountID: TEST_USER_ACCOUNT_ID}), + ]), + })])); + + // Now we remove the reaction + Report.removeEmojiReaction(REPORT_ID, resultAction, EMOJI); + return waitForPromisesToResolve(); + }) + .then(() => { + // Expect that the reaction is removed + const resultAction = _.first(_.values(reportActions)); + + expect(resultAction.message[0].reactions).toHaveLength(0); + }) + .then(() => { + const resultAction = _.first(_.values(reportActions)); + + // Add the reaction to the comment, but two times with different variations + Report.addEmojiReaction(REPORT_ID, resultAction, EMOJI); + return waitForPromisesToResolve() + .then(() => { + const updatedResultAction = _.first(_.values(reportActions)); + Report.addEmojiReaction( + REPORT_ID, + updatedResultAction, + EMOJI, + 2, + ); + return waitForPromisesToResolve(); + }) + .then(() => { + const updatedResultAction = _.first(_.values(reportActions)); + + // Expect to have the reaction on the message + expect(updatedResultAction.message[0].reactions) + .toEqual(expect.arrayContaining([ + expect.objectContaining({ + emoji: EMOJI_NAME, + users: expect.arrayContaining([ + expect.objectContaining({ + accountID: TEST_USER_ACCOUNT_ID, + }), + expect.objectContaining({ + accountID: TEST_USER_ACCOUNT_ID, + skinTone: EMOJI_SKIN_TONE, + }), + ]), + })])); + + // Now we remove the reaction, and expect that both variations are removed + Report.removeEmojiReaction(REPORT_ID, updatedResultAction, EMOJI); + return waitForPromisesToResolve(); + }) + .then(() => { + // Expect that the reaction is removed + const updatedResultAction = _.first(_.values(reportActions)); + + expect(updatedResultAction.message[0].reactions).toHaveLength(0); + }); + }); + }); + + it('shouldn\'t add the same reaction twice when changing preferred skin color and reaction doesn\'t support skin colors', () => { + global.fetch = TestHelper.getGlobalFetchMock(); + + const TEST_USER_ACCOUNT_ID = 1; + const TEST_USER_LOGIN = 'test@test.com'; + const REPORT_ID = 1; + const EMOJI_CODE = '😄'; + const EMOJI_NAME = 'smile'; + const EMOJI = { + code: EMOJI_CODE, + name: EMOJI_NAME, + }; + + let reportActions; + + Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, + callback: val => reportActions = val, + }); + + // Set up Onyx with some test user data + return TestHelper.signInWithTestUser(TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN) + .then(() => { + User.subscribeToUserEvents(); + return waitForPromisesToResolve(); + }) + .then(() => TestHelper.setPersonalDetails(TEST_USER_LOGIN, TEST_USER_ACCOUNT_ID)) + .then(() => { + // This is a fire and forget response, but once it completes we should be able to verify that we + // have an "optimistic" report action in Onyx. + Report.addComment(REPORT_ID, 'Testing a comment'); + return waitForPromisesToResolve(); + }) + .then(() => { + const resultAction = _.first(_.values(reportActions)); + + // Add a reaction to the comment + Report.toggleEmojiReaction(REPORT_ID, resultAction, EMOJI); + return waitForPromisesToResolve(); + }) + .then(() => { + const resultAction = _.first(_.values(reportActions)); + + // Now we toggle the reaction while the skin tone has changed. + // As the emoji doesn't support skin tones, the emoji + // should get removed instead of added again. + Report.toggleEmojiReaction(REPORT_ID, resultAction, EMOJI, 2); + return waitForPromisesToResolve(); + }) + .then(() => { + const resultAction = _.first(_.values(reportActions)); + + // Expect to have the reaction on the message + expect(resultAction.message[0].reactions).toHaveLength(0); + }); + }); });