diff --git a/src/CONST.js b/src/CONST.js index 88282c4b68e4..7ebdd6bad406 100755 --- a/src/CONST.js +++ b/src/CONST.js @@ -174,6 +174,7 @@ const CONST = { }, DATE: { MOMENT_FORMAT_STRING: 'YYYY-MM-DD', + SQL_DATE_TIME: 'YYYY-MM-DD HH:mm:ss', UNIX_EPOCH: '1970-01-01 00:00:00.000', MAX_DATE: '9999-12-31', MIN_DATE: '0001-01-01', diff --git a/src/components/AttachmentCarousel/index.js b/src/components/AttachmentCarousel/index.js index 85f3abcc90aa..2c72321f490b 100644 --- a/src/components/AttachmentCarousel/index.js +++ b/src/components/AttachmentCarousel/index.js @@ -270,7 +270,11 @@ class AttachmentCarousel extends React.Component { /** * Defines how a single attachment should be rendered - * @param {{ isAuthTokenRequired: Boolean, source: String, file: { name: String } }} item + * @param {Object} item + * @param {Boolean} item.isAuthTokenRequired + * @param {String} item.source + * @param {Object} item.file + * @param {String} item.file.name * @returns {JSX.Element} */ renderItem({item}) { diff --git a/src/components/AttachmentPicker/index.native.js b/src/components/AttachmentPicker/index.native.js index b0b1222316f0..b4b7d0b04c4e 100644 --- a/src/components/AttachmentPicker/index.native.js +++ b/src/components/AttachmentPicker/index.native.js @@ -315,7 +315,8 @@ class AttachmentPicker extends Component { /** * Setup native attachment selection to start after this popover closes * - * @param {{pickAttachment: function}} item - an item from this.menuItemData + * @param {Object} item - an item from this.menuItemData + * @param {Function} item.pickAttachment */ selectItem(item) { /* setTimeout delays execution to the frame after the modal closes diff --git a/src/components/Reactions/EmojiReactionsPropTypes.js b/src/components/Reactions/EmojiReactionsPropTypes.js new file mode 100644 index 000000000000..ace88abefcd9 --- /dev/null +++ b/src/components/Reactions/EmojiReactionsPropTypes.js @@ -0,0 +1,31 @@ +import PropTypes from 'prop-types'; + +/** All the emoji reactions for the report action. An object that looks like this: + "emojiReactions": { + "+1": { // The emoji added to the action + "createdAt": "2021-01-01 00:00:00", + "users": { + 2352342: { // The accountID of the user who added this emoji + "skinTones": { + "1": "2021-01-01 00:00:00", + "2": "2021-01-01 00:00:00", + }, + }, + }, + }, + }, +*/ +export default PropTypes.objectOf( + PropTypes.shape({ + /** The time the emoji was added */ + createdAt: PropTypes.string, + + /** All the users who have added this emoji */ + users: PropTypes.objectOf( + PropTypes.shape({ + /** The skin tone which was used and also the timestamp of when it was added */ + skinTones: PropTypes.objectOf(PropTypes.string), + }), + ), + }), +); diff --git a/src/components/Reactions/MiniQuickEmojiReactions.js b/src/components/Reactions/MiniQuickEmojiReactions.js index 9973c5fae083..4d918f5e7f58 100644 --- a/src/components/Reactions/MiniQuickEmojiReactions.js +++ b/src/components/Reactions/MiniQuickEmojiReactions.js @@ -12,7 +12,7 @@ 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 {baseQuickEmojiReactionsPropTypes, baseQuickEmojiReactionsDefaultProps} from './QuickEmojiReactions/BaseQuickEmojiReactions'; import withLocalize, {withLocalizePropTypes} from '../withLocalize'; import compose from '../../libs/compose'; import ONYXKEYS from '../../ONYXKEYS'; @@ -40,6 +40,7 @@ const propTypes = { }; const defaultProps = { + ...baseQuickEmojiReactionsDefaultProps, onEmojiPickerClosed: () => {}, preferredSkinTone: CONST.EMOJI_DEFAULT_SKIN_TONE, reportAction: {}, @@ -61,7 +62,7 @@ function MiniQuickEmojiReactions(props) { EmojiPickerAction.showEmojiPicker( props.onEmojiPickerClosed, (emojiCode, emojiObject) => { - props.onEmojiSelected(emojiObject); + props.onEmojiSelected(emojiObject, props.emojiReactions); }, ref.current, undefined, @@ -77,7 +78,7 @@ function MiniQuickEmojiReactions(props) { key={emoji.name} isDelayButtonStateComplete={false} tooltipText={`:${EmojiUtils.getLocalizedEmojiName(emoji.name, props.preferredLocale)}:`} - onPress={Session.checkIfActionIsAllowed(() => props.onEmojiSelected(emoji))} + onPress={Session.checkIfActionIsAllowed(() => props.onEmojiSelected(emoji, props.emojiReactions))} > {EmojiUtils.getPreferredEmojiCode(emoji, props.preferredSkinTone)} @@ -105,9 +106,15 @@ MiniQuickEmojiReactions.propTypes = propTypes; MiniQuickEmojiReactions.defaultProps = defaultProps; export default compose( withLocalize, + // ESLint throws an error because it can't see that emojiReactions is defined in props. It is defined in props, but + // because of a couple spread operators, I think that's why ESLint struggles to see it + // eslint-disable-next-line rulesdir/onyx-props-must-have-default withOnyx({ preferredSkinTone: { key: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, }, + emojiReactions: { + key: ({reportActionID}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${reportActionID}`, + }, }), )(MiniQuickEmojiReactions); diff --git a/src/components/Reactions/QuickEmojiReactions/BaseQuickEmojiReactions.js b/src/components/Reactions/QuickEmojiReactions/BaseQuickEmojiReactions.js index 7040f579863e..69ff37338c2d 100644 --- a/src/components/Reactions/QuickEmojiReactions/BaseQuickEmojiReactions.js +++ b/src/components/Reactions/QuickEmojiReactions/BaseQuickEmojiReactions.js @@ -10,9 +10,12 @@ import styles from '../../../styles/styles'; import ONYXKEYS from '../../../ONYXKEYS'; import Tooltip from '../../Tooltip'; import * as EmojiUtils from '../../../libs/EmojiUtils'; +import EmojiReactionsPropTypes from '../EmojiReactionsPropTypes'; import * as Session from '../../../libs/actions/Session'; const baseQuickEmojiReactionsPropTypes = { + emojiReactions: EmojiReactionsPropTypes, + /** * Callback to fire when an emoji is selected. */ @@ -39,6 +42,7 @@ const baseQuickEmojiReactionsPropTypes = { }; const baseQuickEmojiReactionsDefaultProps = { + emojiReactions: {}, onWillShowPicker: () => {}, onPressOpenPicker: () => {}, reportAction: {}, @@ -67,7 +71,7 @@ function BaseQuickEmojiReactions(props) { props.onEmojiSelected(emoji))} + onPress={Session.checkIfActionIsAllowed(() => props.onEmojiSelected(emoji, props.emojiReactions))} /> @@ -90,9 +94,12 @@ export default withOnyx({ preferredSkinTone: { key: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, }, + emojiReactions: { + key: ({reportActionID}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${reportActionID}`, + }, preferredLocale: { key: ONYXKEYS.NVP_PREFERRED_LOCALE, }, })(BaseQuickEmojiReactions); -export {baseQuickEmojiReactionsPropTypes}; +export {baseQuickEmojiReactionsPropTypes, baseQuickEmojiReactionsDefaultProps}; diff --git a/src/components/Reactions/ReportActionItemReactions.js b/src/components/Reactions/ReportActionItemEmojiReactions.js similarity index 52% rename from src/components/Reactions/ReportActionItemReactions.js rename to src/components/Reactions/ReportActionItemEmojiReactions.js index 823fa065b0c6..bb21f034c11c 100644 --- a/src/components/Reactions/ReportActionItemReactions.js +++ b/src/components/Reactions/ReportActionItemEmojiReactions.js @@ -9,26 +9,14 @@ import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsDefaultPro import withLocalize from '../withLocalize'; import compose from '../../libs/compose'; import * as Report from '../../libs/actions/Report'; +import EmojiReactionsPropTypes from './EmojiReactionsPropTypes'; import Tooltip from '../Tooltip'; import ReactionTooltipContent from './ReactionTooltipContent'; import * as EmojiUtils from '../../libs/EmojiUtils'; import ReportScreenContext from '../../pages/home/ReportScreenContext'; 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, + emojiReactions: EmojiReactionsPropTypes, /** The ID of the reportAction. It is the string representation of the a 64-bit integer. */ reportActionID: PropTypes.string.isRequired, @@ -45,27 +33,66 @@ const propTypes = { const defaultProps = { ...withCurrentUserPersonalDetailsDefaultProps, + emojiReactions: {}, }; -function ReportActionItemReactions(props) { +function ReportActionItemEmojiReactions(props) { const {reactionListRef} = useContext(ReportScreenContext); const popoverReactionListAnchor = useRef(null); - const reactionsWithCount = _.filter(props.reactions, (reaction) => reaction.users.length > 0); + let totalReactionCount = 0; + + // Each emoji is sorted by the oldest timestamp of user reactions so that they will always appear in the same order for everyone + const sortedReactions = _.sortBy(props.emojiReactions, (emojiReaction, emojiName) => { + // Since the emojiName is only stored as the object key, when _.sortBy() runs, the object is converted to an array and the + // keys are lost. To keep from losing the emojiName, it's copied to the emojiReaction object. + // eslint-disable-next-line no-param-reassign + emojiReaction.emojiName = emojiName; + const oldestUserReactionTimestamp = _.chain(emojiReaction.users) + .reduce((allTimestampsArray, userData) => { + if (!userData) { + return allTimestampsArray; + } + _.each(userData.skinTones, (createdAt) => { + allTimestampsArray.push(createdAt); + }); + return allTimestampsArray; + }, []) + .sort() + .first() + .value(); + + // Just in case two emojis have the same timestamp, also combine the timestamp with the + // emojiName so that the order will always be the same. Without this, the order can be pretty random + // and shift around a little bit. + return (oldestUserReactionTimestamp || emojiReaction.createdAt) + emojiName; + }); return ( - {_.map(reactionsWithCount, (reaction) => { - const reactionCount = reaction.users.length; - const reactionUsers = _.map(reaction.users, (sender) => sender.accountID); - const emoji = EmojiUtils.findEmojiByName(reaction.emoji); - const emojiCodes = EmojiUtils.getUniqueEmojiCodes(emoji, reaction.users); - const hasUserReacted = Report.hasAccountIDReacted(props.currentUserPersonalDetails.accountID, reactionUsers); + {_.map(sortedReactions, (reaction) => { + const reactionEmojiName = reaction.emojiName; + const usersWithReactions = _.pick(reaction.users, _.identity); + let reactionCount = 0; + + // Loop through the users who have reacted and see how many skintones they reacted with so that we get the total count + _.forEach(usersWithReactions, (user) => { + reactionCount += _.size(user.skinTones); + }); + if (!reactionCount) { + return null; + } + totalReactionCount += reactionCount; + const emojiAsset = EmojiUtils.findEmojiByName(reactionEmojiName); + const emojiCodes = EmojiUtils.getUniqueEmojiCodes(emojiAsset, reaction.users); + const hasUserReacted = Report.hasAccountIDEmojiReacted(props.currentUserPersonalDetails.accountID, reaction.users); + const reactionUsers = _.keys(usersWithReactions); + const reactionUserAccountIDs = _.map(reactionUsers, Number); const onPress = () => { - props.toggleReaction(emoji); + props.toggleReaction(emojiAsset); }; const onReactionListOpen = (event) => { @@ -76,14 +103,14 @@ function ReportActionItemReactions(props) { ( )} renderTooltipContentKey={[..._.map(reactionUsers, (user) => user.toString()), ...emojiCodes]} - key={reaction.emoji} + key={reactionEmojiName} > ); })} - {reactionsWithCount.length > 0 && ( + {totalReactionCount > 0 && ( { * 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 {Object} emojiAsset + * @param {String} emojiAsset.name + * @param {String} emojiAsset.code + * @param {String[]} [emojiAsset.types] * @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); - } +const getUniqueEmojiCodes = (emojiAsset, users) => { + const uniqueEmojiCodes = []; + _.each(users, (userSkinTones) => { + _.each(lodashGet(userSkinTones, 'skinTones'), (createdAt, skinTone) => { + const emojiCode = getPreferredEmojiCode(emojiAsset, skinTone); + if (emojiCode && !uniqueEmojiCodes.includes(emojiCode)) { + uniqueEmojiCodes.push(emojiCode); + } + }); }); - return emojiCodes; + return uniqueEmojiCodes; }; export { diff --git a/src/libs/actions/Report.js b/src/libs/actions/Report.js index 3c2a7be271cd..7a6cbd1d9d45 100644 --- a/src/libs/actions/Report.js +++ b/src/libs/actions/Report.js @@ -4,6 +4,7 @@ import lodashGet from 'lodash/get'; import ExpensiMark from 'expensify-common/lib/ExpensiMark'; import Onyx from 'react-native-onyx'; import Str from 'expensify-common/lib/str'; +import moment from 'moment'; import ONYXKEYS from '../../ONYXKEYS'; import * as Pusher from '../Pusher/pusher'; import LocalNotification from '../Notification/LocalNotification'; @@ -1554,179 +1555,134 @@ 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: 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 {Number} accountID - * @param {Array} users + * Uses the NEW FORMAT for "emojiReactions" + * @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 - ); +function hasAccountIDEmojiReacted(accountID, users, skinTone) { + if (_.isUndefined(skinTone)) { + return Boolean(users[accountID]); + } + const usersReaction = users[accountID]; + if (!usersReaction || !usersReaction.skinTones || !_.size(usersReaction.skinTones)) { + return false; + } + return Boolean(usersReaction.skinTones[skinTone]); } /** * Adds a reaction to the report action. + * Uses the NEW FORMAT for "emojiReactions" * @param {String} reportID - * @param {Object} originalReportAction - * @param {{ name: string, code: string, types: string[] }} emoji - * @param {number} [skinTone] Optional. + * @param {String} reportActionID + * @param {Object} emoji + * @param {String} emoji.name + * @param {String} emoji.code + * @param {String[]} [emoji.types] + * @param {Number} [skinTone] */ -function addEmojiReaction(reportID, originalReportAction, emoji, skinTone = preferredSkinTone) { - const originalReportID = ReportUtils.getOriginalReportID(reportID, originalReportAction); - 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, originalReportID); +function addEmojiReaction(reportID, reportActionID, emoji, skinTone = preferredSkinTone) { + const createdAt = moment().utc().format(CONST.DATE.SQL_DATE_TIME); + const optimisticData = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${reportActionID}`, + value: { + [emoji.name]: { + createdAt, + users: { + [currentUserAccountID]: { + skinTones: { + [!_.isUndefined(skinTone) ? skinTone : -1]: createdAt, + }, + }, + }, + }, + }, + }, + ]; const parameters = { - reportID: originalReportID, + reportID, skinTone, emojiCode: emoji.name, - sequenceNumber: originalReportAction.sequenceNumber, - reportActionID: originalReportAction.reportActionID, + reportActionID, + createdAt, + // This will be removed as part of https://github.com/Expensify/App/issues/19535 + useEmojiReactions: true, }; API.write('AddEmojiReaction', parameters, {optimisticData}); } /** * Removes a reaction to the report action. + * Uses the NEW FORMAT for "emojiReactions" * @param {String} reportID - * @param {Object} originalReportAction - * @param {{ name: string, code: string, types: string[] }} emoji + * @param {String} reportActionID + * @param {Object} emoji + * @param {String} emoji.name + * @param {String} emoji.code + * @param {String[]} [emoji.types] */ -function removeEmojiReaction(reportID, originalReportAction, emoji) { - const originalReportID = ReportUtils.getOriginalReportID(reportID, originalReportAction); - 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, originalReportID); +function removeEmojiReaction(reportID, reportActionID, emoji) { + const optimisticData = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${reportActionID}`, + value: { + [emoji.name]: { + users: { + [currentUserAccountID]: null, + }, + }, + }, + }, + ]; const parameters = { - reportID: originalReportID, - sequenceNumber: originalReportAction.sequenceNumber, - reportActionID: originalReportAction.reportActionID, + reportID, + reportActionID, emojiCode: emoji.name, + // This will be removed as part of https://github.com/Expensify/App/issues/19535 + useEmojiReactions: true, }; API.write('RemoveEmojiReaction', parameters, {optimisticData}); } /** * Calls either addEmojiReaction or removeEmojiReaction depending on if the current user has reacted to the report action. + * Uses the NEW FORMAT for "emojiReactions" * @param {String} reportID * @param {String} reportActionID - * @param {Object} reactionEmoji - * @param {number} paramSkinTone - * @returns {Promise} + * @param {Object} reactionObject + * @param {Object} existingReactions + * @param {Number} [paramSkinTone] */ -function toggleEmojiReaction(reportID, reportActionID, reactionEmoji, paramSkinTone = preferredSkinTone) { +function toggleEmojiReaction(reportID, reportActionID, reactionObject, existingReactions, paramSkinTone = preferredSkinTone) { const reportAction = ReportActionsUtils.getReportAction(reportID, reportActionID); if (_.isEmpty(reportAction)) { return; } - const emoji = EmojiUtils.findEmojiByCode(reactionEmoji.code); - 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); - } + // This will get cleaned up as part of https://github.com/Expensify/App/issues/16506 once the old emoji + // format is no longer being used + const emoji = EmojiUtils.findEmojiByCode(reactionObject.code); + const existingReactionObject = lodashGet(existingReactions, [emoji.name]); + + // Only use skin tone if emoji supports it + const skinTone = emoji.types === undefined ? -1 : paramSkinTone; + + if (existingReactionObject && hasAccountIDEmojiReacted(currentUserAccountID, existingReactionObject.users, skinTone)) { + removeEmojiReaction(reportID, reportAction.reportActionID, emoji); + return; } - return addEmojiReaction(reportID, reportAction, emoji, skinTone); + + addEmojiReaction(reportID, reportAction.reportActionID, emoji, skinTone); } /** @@ -1947,10 +1903,8 @@ export { subscribeToNewActionEvent, notifyNewAction, showReportActionNotification, - addEmojiReaction, - removeEmojiReaction, toggleEmojiReaction, - hasAccountIDReacted, + hasAccountIDEmojiReacted, shouldShowReportActionNotification, leaveRoom, setLastOpenedPublicRoom, diff --git a/src/pages/home/report/ContextMenu/ContextMenuActions.js b/src/pages/home/report/ContextMenu/ContextMenuActions.js index 93d83fe87f1a..a7596fdc07ef 100644 --- a/src/pages/home/report/ContextMenu/ContextMenuActions.js +++ b/src/pages/home/report/ContextMenu/ContextMenuActions.js @@ -58,8 +58,8 @@ export default [ } }; - const onEmojiSelected = (emoji) => { - Report.toggleEmojiReaction(reportID, reportAction.reportActionID, emoji); + const toggleEmojiAndCloseMenu = (emoji, existingReactions) => { + Report.toggleEmojiReaction(reportID, reportAction.reportActionID, emoji, existingReactions); closeContextMenu(); }; @@ -67,9 +67,10 @@ export default [ return ( ); @@ -79,7 +80,8 @@ export default [ ); diff --git a/src/pages/home/report/ReportActionItem.js b/src/pages/home/report/ReportActionItem.js index 710df7e30f9c..2b524a6be29f 100644 --- a/src/pages/home/report/ReportActionItem.js +++ b/src/pages/home/report/ReportActionItem.js @@ -42,7 +42,7 @@ import * as ReportActionsUtils from '../../../libs/ReportActionsUtils'; import reportPropTypes from '../../reportPropTypes'; import {ShowContextMenuContext} from '../../../components/ShowContextMenuContext'; import ChronosOOOListActions from '../../../components/ReportActionItem/ChronosOOOListActions'; -import ReportActionItemReactions from '../../../components/Reactions/ReportActionItemReactions'; +import ReportActionItemEmojiReactions from '../../../components/Reactions/ReportActionItemEmojiReactions'; import * as Report from '../../../libs/actions/Report'; import withLocalize from '../../../components/withLocalize'; import Icon from '../../../components/Icon'; @@ -54,6 +54,7 @@ import ReportPreview from '../../../components/ReportActionItem/ReportPreview'; import ReportActionItemDraft from './ReportActionItemDraft'; import TaskPreview from '../../../components/ReportActionItem/TaskPreview'; import TaskAction from '../../../components/ReportActionItem/TaskAction'; +import EmojiReactionsPropTypes from '../../../components/Reactions/EmojiReactionsPropTypes'; import TaskView from '../../../components/ReportActionItem/TaskView'; import MoneyReportView from '../../../components/ReportActionItem/MoneyReportView'; import * as Session from '../../../libs/actions/Session'; @@ -93,7 +94,8 @@ const propTypes = { /** Stores user's preferred skin tone */ preferredSkinTone: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), - /** All of the personalDetails */ + ...windowDimensionsPropTypes, + emojiReactions: EmojiReactionsPropTypes, personalDetailsList: PropTypes.objectOf(personalDetailsPropType), /** Is this the only report action on the report? */ @@ -103,6 +105,7 @@ const propTypes = { const defaultProps = { draftMessage: '', preferredSkinTone: CONST.EMOJI_DEFAULT_SKIN_TONE, + emojiReactions: {}, personalDetailsList: {}, shouldShowSubscriptAvatar: false, hasOutstandingIOU: false, @@ -205,9 +208,9 @@ function ReportActionItem(props) { const toggleReaction = useCallback( (emoji) => { - Report.toggleEmojiReaction(props.report.reportID, props.action.reportActionID, emoji); + Report.toggleEmojiReaction(props.report.reportID, props.action.reportActionID, emoji, props.emojiReactions); }, - [props.report, props.action], + [props.report, props.action, props.emojiReactions], ); /** @@ -331,9 +334,6 @@ function ReportActionItem(props) { ); } - - const reactions = _.get(props, ['action', 'message', 0, 'reactions'], []); - const hasReactions = reactions.length > 0; const numberOfThreadReplies = _.get(props, ['action', 'childVisibleActionCount'], 0); const hasReplies = numberOfThreadReplies > 0; @@ -349,25 +349,24 @@ function ReportActionItem(props) { !_.isEmpty(item))} /> )} - {hasReactions && ( - - { - if (Session.isAnonymousUser()) { - hideContextMenu(false); - - InteractionManager.runAfterInteractions(() => { - Session.signOutAndRedirectToSignIn(); - }); - } else { - toggleReaction(emoji); - } - }} - /> - - )} + + { + if (Session.isAnonymousUser()) { + hideContextMenu(false); + + InteractionManager.runAfterInteractions(() => { + Session.signOutAndRedirectToSignIn(); + }); + } else { + toggleReaction(emoji); + } + }} + /> + + {shouldDisplayThreadReplies && ( `${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${action.reportActionID}`, + }, }), )( memo( @@ -561,6 +563,7 @@ export default compose( prevProps.isMostRecentIOUReportAction === nextProps.isMostRecentIOUReportAction && prevProps.hasOutstandingIOU === nextProps.hasOutstandingIOU && prevProps.shouldDisplayNewMarker === nextProps.shouldDisplayNewMarker && + _.isEqual(prevProps.emojiReactions, nextProps.emojiReactions) && _.isEqual(prevProps.action, nextProps.action) && lodashGet(prevProps.report, 'statusNum') === lodashGet(nextProps.report, 'statusNum') && lodashGet(prevProps.report, 'stateNum') === lodashGet(nextProps.report, 'stateNum') && diff --git a/tests/actions/ReportTest.js b/tests/actions/ReportTest.js index e896f7aca487..c50e8b250104 100644 --- a/tests/actions/ReportTest.js +++ b/tests/actions/ReportTest.js @@ -521,14 +521,23 @@ describe('actions/Report', () => { const EMOJI = { code: EMOJI_CODE, name: EMOJI_NAME, + types: ['👍🏿', '👍🏾', '👍🏽', '👍🏼', '👍🏻'], }; let reportActions; - Onyx.connect({ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, callback: (val) => (reportActions = val), }); + const reportActionsReactions = {}; + Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS, + callback: (val, key) => { + reportActionsReactions[key] = val; + }, + }); + let reportAction; + let reportActionID; // Set up Onyx with some test user data return TestHelper.signInWithTestUser(TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN) @@ -544,76 +553,70 @@ describe('actions/Report', () => { return waitForPromisesToResolve(); }) .then(() => { - const resultAction = _.first(_.values(reportActions)); + reportAction = _.first(_.values(reportActions)); + reportActionID = reportAction.reportActionID; // Add a reaction to the comment - Report.addEmojiReaction(REPORT_ID, resultAction, EMOJI); + Report.toggleEmojiReaction(REPORT_ID, reportActionID, EMOJI); return waitForPromisesToResolve(); }) .then(() => { - const resultAction = _.first(_.values(reportActions)); + // Expect the reaction to exist in the reportActionsReactions collection + expect(reportActionsReactions).toHaveProperty(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${reportActionID}`); - // 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})]), - }), - ]), - ); + // Expect the reaction to have the emoji on it + const reportActionReaction = reportActionsReactions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${reportActionID}`]; + expect(reportActionReaction).toHaveProperty(EMOJI.name); + + // Expect the emoji to have the user accountID + const reportActionReactionEmoji = reportActionReaction[EMOJI.name]; + expect(reportActionReactionEmoji.users).toHaveProperty(`${TEST_USER_ACCOUNT_ID}`); // Now we remove the reaction - Report.removeEmojiReaction(REPORT_ID, resultAction, EMOJI); + Report.toggleEmojiReaction(REPORT_ID, reportActionID, EMOJI, reportActionReaction); return waitForPromisesToResolve(); }) .then(() => { - // Expect that the reaction is removed - const resultAction = _.first(_.values(reportActions)); - - expect(resultAction.message[0].reactions).toHaveLength(0); + // Expect the reaction to have null where the users reaction used to be + expect(reportActionsReactions).toHaveProperty(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${reportActionID}`); + const reportActionReaction = reportActionsReactions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${reportActionID}`]; + expect(reportActionReaction[EMOJI.name].users[TEST_USER_ACCOUNT_ID]).toBeNull(); }) .then(() => { - const resultAction = _.first(_.values(reportActions)); - - // Add the reaction to the comment, but two times with different variations - Report.addEmojiReaction(REPORT_ID, resultAction, EMOJI); + // Add the same reaction to the same report action with a different skintone + Report.toggleEmojiReaction(REPORT_ID, reportActionID, EMOJI); return waitForPromisesToResolve() .then(() => { - const updatedResultAction = _.first(_.values(reportActions)); - Report.addEmojiReaction(REPORT_ID, updatedResultAction, EMOJI, 2); + const reportActionReaction = reportActionsReactions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${reportActionID}`]; + Report.toggleEmojiReaction(REPORT_ID, reportActionID, EMOJI, reportActionReaction, EMOJI_SKIN_TONE); 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, - }), - ]), - }), - ]), - ); + // Expect the reaction to exist in the reportActionsReactions collection + expect(reportActionsReactions).toHaveProperty(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${reportActionID}`); + + // Expect the reaction to have the emoji on it + const reportActionReaction = reportActionsReactions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${reportActionID}`]; + expect(reportActionReaction).toHaveProperty(EMOJI.name); + + // Expect the emoji to have the user accountID + const reportActionReactionEmoji = reportActionReaction[EMOJI.name]; + expect(reportActionReactionEmoji.users).toHaveProperty(`${TEST_USER_ACCOUNT_ID}`); + + // Expect two different skintone reactions + const reportActionReactionEmojiUserSkinTones = reportActionReactionEmoji.users[TEST_USER_ACCOUNT_ID].skinTones; + expect(reportActionReactionEmojiUserSkinTones).toHaveProperty('-1'); + expect(reportActionReactionEmojiUserSkinTones).toHaveProperty('2'); // Now we remove the reaction, and expect that both variations are removed - Report.removeEmojiReaction(REPORT_ID, updatedResultAction, EMOJI); + Report.toggleEmojiReaction(REPORT_ID, reportActionID, EMOJI, reportActionReaction); return waitForPromisesToResolve(); }) .then(() => { - // Expect that the reaction is removed - const updatedResultAction = _.first(_.values(reportActions)); - - expect(updatedResultAction.message[0].reactions).toHaveLength(0); + // Expect the reaction to have null where the users reaction used to be + expect(reportActionsReactions).toHaveProperty(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${reportActionID}`); + const reportActionReaction = reportActionsReactions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${reportActionID}`]; + expect(reportActionReaction[EMOJI.name].users[TEST_USER_ACCOUNT_ID]).toBeNull(); }); }); }); @@ -632,11 +635,19 @@ describe('actions/Report', () => { }; let reportActions; - Onyx.connect({ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, callback: (val) => (reportActions = val), }); + const reportActionsReactions = {}; + Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS, + callback: (val, key) => { + reportActionsReactions[key] = val; + }, + }); + + let resultAction; // Set up Onyx with some test user data return TestHelper.signInWithTestUser(TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN) @@ -652,26 +663,25 @@ describe('actions/Report', () => { return waitForPromisesToResolve(); }) .then(() => { - const resultAction = _.first(_.values(reportActions)); + resultAction = _.first(_.values(reportActions)); // Add a reaction to the comment - Report.toggleEmojiReaction(REPORT_ID, resultAction.reportActionID, EMOJI); + Report.toggleEmojiReaction(REPORT_ID, resultAction.reportActionID, 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.reportActionID, EMOJI, 2); + const reportActionReaction = reportActionsReactions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${resultAction.reportActionID}`]; + Report.toggleEmojiReaction(REPORT_ID, resultAction.reportActionID, EMOJI, reportActionReaction, 2); return waitForPromisesToResolve(); }) .then(() => { - const resultAction = _.first(_.values(reportActions)); - - // Expect to have the reaction on the message - expect(resultAction.message[0].reactions).toHaveLength(0); + // Expect the reaction to have null where the users reaction used to be + expect(reportActionsReactions).toHaveProperty(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${resultAction.reportActionID}`); + const reportActionReaction = reportActionsReactions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${resultAction.reportActionID}`]; + expect(reportActionReaction[EMOJI.name].users[TEST_USER_ACCOUNT_ID]).toBeNull(); }); }); });