diff --git a/src/components/Reactions/ReportActionItemEmojiReactions.js b/src/components/Reactions/ReportActionItemEmojiReactions.js index 7ead2ab67ae7..5fdf74f877dd 100644 --- a/src/components/Reactions/ReportActionItemEmojiReactions.js +++ b/src/components/Reactions/ReportActionItemEmojiReactions.js @@ -9,7 +9,6 @@ import AddReactionBubble from './AddReactionBubble'; import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsDefaultProps, withCurrentUserPersonalDetailsPropTypes} from '../withCurrentUserPersonalDetails'; 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'; @@ -52,71 +51,42 @@ function ReportActionItemEmojiReactions(props) { const reportAction = props.reportAction; const reportActionID = reportAction.reportActionID; - // 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; - }); - - const formattedReactions = _.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(emojiAsset); - }; - - const onReactionListOpen = (event) => { - reactionListRef.current.showReactionList(event, popoverReactionListAnchors.current[reactionEmojiName], reactionEmojiName, reportActionID); - }; - - return { - reactionEmojiName, - emojiCodes, - reactionUserAccountIDs, - onPress, - reactionUsers, - reactionCount, - hasUserReacted, - onReactionListOpen, - pendingAction: reaction.pendingAction, - }; - }); + const formattedReactions = _.chain(props.emojiReactions) + .map((emojiReaction, emojiName) => { + const {emoji, emojiCodes, reactionCount, hasUserReacted, userAccountIDs, oldestTimestamp} = EmojiUtils.getEmojiReactionDetails( + emojiName, + emojiReaction, + props.currentUserPersonalDetails.accountID, + ); + + if (reactionCount === 0) { + return null; + } + totalReactionCount += reactionCount; + + const onPress = () => { + props.toggleReaction(emoji); + }; + + const onReactionListOpen = (event) => { + reactionListRef.current.showReactionList(event, popoverReactionListAnchors.current[emojiName], emojiName, reportActionID); + }; + + return { + emojiCodes, + userAccountIDs, + reactionCount, + hasUserReacted, + oldestTimestamp, + onPress, + onReactionListOpen, + reactionEmojiName: emojiName, + pendingAction: emojiReaction.pendingAction, + }; + }) + // Each emoji is sorted by the oldest timestamp of user reactions so that they will always appear in the same order for everyone + .sortBy('oldestTimestamp') + .value(); return ( totalReactionCount > 0 && ( @@ -131,11 +101,11 @@ function ReportActionItemEmojiReactions(props) { )} - renderTooltipContentKey={[..._.map(reaction.reactionUsers, (user) => user.toString()), ...reaction.emojiCodes]} + renderTooltipContentKey={[..._.map(reaction.userAccountIDs, String), ...reaction.emojiCodes]} key={reaction.reactionEmojiName} > @@ -148,7 +118,6 @@ function ReportActionItemEmojiReactions(props) { count={reaction.reactionCount} emojiCodes={reaction.emojiCodes} onPress={reaction.onPress} - reactionUsers={reaction.reactionUsers} hasUserReacted={reaction.hasUserReacted} onReactionListOpen={reaction.onReactionListOpen} shouldBlockReactions={props.shouldBlockReactions} diff --git a/src/libs/EmojiUtils.js b/src/libs/EmojiUtils.js index 344d0c3bd397..05ad1bd3c2ce 100644 --- a/src/libs/EmojiUtils.js +++ b/src/libs/EmojiUtils.js @@ -3,6 +3,8 @@ import {getUnixTime} from 'date-fns'; import Str from 'expensify-common/lib/str'; import Onyx from 'react-native-onyx'; import lodashGet from 'lodash/get'; +import lodashMin from 'lodash/min'; +import lodashSum from 'lodash/sum'; import ONYXKEYS from '../ONYXKEYS'; import CONST from '../CONST'; import emojisTrie from './EmojiTrie'; @@ -80,7 +82,7 @@ const getEmojiUnicode = _.memoize((input) => { const pairs = []; - // Some Emojis in UTF-16 are stored as pair of 2 Unicode characters (eg Flags) + // Some Emojis in UTF-16 are stored as a pair of 2 Unicode characters (e.g. Flags) // The first char is generally between the range U+D800 to U+DBFF called High surrogate // & the second char between the range U+DC00 to U+DFFF called low surrogate // More info in the following links: @@ -474,7 +476,7 @@ const getPreferredEmojiCode = (emoji, preferredSkinTone) => { /** * 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. + * emoji, sorted by the reaction timestamp. * @param {Object} emojiAsset * @param {String} emojiAsset.name * @param {String} emojiAsset.code @@ -483,16 +485,110 @@ const getPreferredEmojiCode = (emoji, preferredSkinTone) => { * @return {string[]} * */ 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); + const emojiCodes = _.reduce( + users, + (result, userSkinTones) => { + _.each(lodashGet(userSkinTones, 'skinTones'), (createdAt, skinTone) => { + const emojiCode = getPreferredEmojiCode(emojiAsset, skinTone); + if (!!emojiCode && (!result[emojiCode] || createdAt < result[emojiCode])) { + // eslint-disable-next-line no-param-reassign + result[emojiCode] = createdAt; + } + }); + return result; + }, + {}, + ); + + return _.chain(emojiCodes) + .pairs() + .sortBy((entry) => new Date(entry[1])) // Sort by values (timestamps) + .map((entry) => entry[0]) // Extract keys (emoji codes) + .value(); +}; + +/** + * Given an emoji reaction object and its name, it populates it with the oldest reaction timestamps. + * @param {Object} emoji + * @param {String} emojiName + * @returns {Object} + */ +const enrichEmojiReactionWithTimestamps = (emoji, emojiName) => { + let oldestEmojiTimestamp = null; + + const usersWithTimestamps = _.chain(emoji.users) + .pick(_.identity) + .mapObject((user, id) => { + const oldestUserTimestamp = lodashMin(_.values(user.skinTones)); + + if (!oldestEmojiTimestamp || oldestUserTimestamp < oldestEmojiTimestamp) { + oldestEmojiTimestamp = oldestUserTimestamp; } - }); - }); - return uniqueEmojiCodes; + + return { + ...user, + id, + oldestTimestamp: oldestUserTimestamp, + }; + }) + .value(); + + return { + ...emoji, + users: usersWithTimestamps, + // 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. + oldestTimestamp: (oldestEmojiTimestamp || emoji.createdAt) + emojiName, + }; +}; + +/** + * Returns true if the accountID has reacted to the report action (with the given skin tone). + * Uses the NEW FORMAT for "emojiReactions" + * @param {String} accountID + * @param {Array} usersReactions - all the users reactions + * @param {Number} [skinTone] + * @returns {boolean} + */ +function hasAccountIDEmojiReacted(accountID, usersReactions, skinTone) { + if (_.isUndefined(skinTone)) { + return Boolean(usersReactions[accountID]); + } + const userReaction = usersReactions[accountID]; + if (!userReaction || !userReaction.skinTones || !_.size(userReaction.skinTones)) { + return false; + } + return Boolean(userReaction.skinTones[skinTone]); +} + +/** + * Given an emoji reaction and current user's account ID, it returns the reusable details of the emoji reaction. + * @param {String} emojiName + * @param {Object} reaction + * @param {String} currentUserAccountID + * @returns {Object} + */ +const getEmojiReactionDetails = (emojiName, reaction, currentUserAccountID) => { + const {users, oldestTimestamp} = enrichEmojiReactionWithTimestamps(reaction, emojiName); + + const emoji = findEmojiByName(emojiName); + const emojiCodes = getUniqueEmojiCodes(emoji, users); + const reactionCount = lodashSum(_.map(users, (user) => _.size(user.skinTones))); + const hasUserReacted = hasAccountIDEmojiReacted(currentUserAccountID, users); + const userAccountIDs = _.chain(users) + .sortBy('oldestTimestamp') + .map((user) => Number(user.id)) + .value(); + + return { + emoji, + emojiCodes, + reactionCount, + hasUserReacted, + userAccountIDs, + oldestTimestamp, + }; }; export { @@ -511,8 +607,10 @@ export { getPreferredSkinToneIndex, getPreferredEmojiCode, getUniqueEmojiCodes, + getEmojiReactionDetails, replaceAndExtractEmojis, extractEmojis, getAddedEmojis, isFirstLetterEmoji, + hasAccountIDEmojiReacted, }; diff --git a/src/libs/PersonalDetailsUtils.js b/src/libs/PersonalDetailsUtils.js index 6b9335ab263d..29c49427bc81 100644 --- a/src/libs/PersonalDetailsUtils.js +++ b/src/libs/PersonalDetailsUtils.js @@ -36,21 +36,21 @@ function getDisplayNameOrDefault(passedPersonalDetails, pathToDisplayName, defau * @returns {Array} - Array of personal detail objects */ function getPersonalDetailsByIDs(accountIDs, currentUserAccountID, shouldChangeUserDisplayName = false) { - const result = []; - _.each( - _.filter(personalDetails, (detail) => accountIDs.includes(detail.accountID)), - (detail) => { + return _.chain(accountIDs) + .filter((accountID) => !!allPersonalDetails[accountID]) + .map((accountID) => { + const detail = allPersonalDetails[accountID]; + if (shouldChangeUserDisplayName && currentUserAccountID === detail.accountID) { - result.push({ + return { ...detail, displayName: Localize.translateLocal('common.you'), - }); - } else { - result.push(detail); + }; } - }, - ); - return result; + + return detail; + }) + .value(); } /** diff --git a/src/libs/actions/Report.js b/src/libs/actions/Report.js index ffb3238f004b..dc881252e4d8 100644 --- a/src/libs/actions/Report.js +++ b/src/libs/actions/Report.js @@ -1734,25 +1734,6 @@ function clearIOUError(reportID) { Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {errorFields: {iou: null}}); } -/** - * Returns true if the accountID has reacted to the report action (with the given skin tone). - * Uses the NEW FORMAT for "emojiReactions" - * @param {String} accountID - * @param {Array} users - * @param {Number} [skinTone] - * @returns {boolean} - */ -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" @@ -1882,7 +1863,7 @@ function toggleEmojiReaction(reportID, reportAction, reactionObject, existingRea // Only use skin tone if emoji supports it const skinTone = emoji.types === undefined ? -1 : paramSkinTone; - if (existingReactionObject && hasAccountIDEmojiReacted(currentUserAccountID, existingReactionObject.users, skinTone)) { + if (existingReactionObject && EmojiUtils.hasAccountIDEmojiReacted(currentUserAccountID, existingReactionObject.users, skinTone)) { removeEmojiReaction(originalReportID, reportAction.reportActionID, emoji); return; } @@ -2452,7 +2433,6 @@ export { notifyNewAction, showReportActionNotification, toggleEmojiReaction, - hasAccountIDEmojiReacted, shouldShowReportActionNotification, leaveRoom, inviteToRoom, diff --git a/src/pages/home/report/ReactionList/PopoverReactionList/BasePopoverReactionList.js b/src/pages/home/report/ReactionList/PopoverReactionList/BasePopoverReactionList.js index 9303d7a5bc39..32433cc80ca5 100644 --- a/src/pages/home/report/ReactionList/PopoverReactionList/BasePopoverReactionList.js +++ b/src/pages/home/report/ReactionList/PopoverReactionList/BasePopoverReactionList.js @@ -4,7 +4,6 @@ import lodashGet from 'lodash/get'; import _ from 'underscore'; import {withOnyx} from 'react-native-onyx'; import PropTypes from 'prop-types'; -import * as Report from '../../../../../libs/actions/Report'; import withLocalize, {withLocalizePropTypes} from '../../../../../components/withLocalize'; import PopoverWithMeasuredContent from '../../../../../components/PopoverWithMeasuredContent'; import BaseReactionList from '../BaseReactionList'; @@ -121,30 +120,27 @@ class BasePopoverReactionList extends React.Component { * Get the reaction information. * * @param {Object} selectedReaction + * @param {String} emojiName * @returns {Object} */ - getReactionInformation(selectedReaction) { + getReactionInformation(selectedReaction, emojiName) { if (!selectedReaction) { return { emojiName: '', - emojiCount: 0, + reactionCount: 0, emojiCodes: [], hasUserReacted: false, users: [], }; } - const reactionUsers = _.pick(selectedReaction.users, _.identity); - const emojiCount = _.map(reactionUsers, (user) => user).length; - const userAccountIDs = _.map(reactionUsers, (user, accountID) => Number(accountID)); - const emojiName = selectedReaction.emojiName; - const emoji = EmojiUtils.findEmojiByName(emojiName); - const emojiCodes = EmojiUtils.getUniqueEmojiCodes(emoji, selectedReaction.users); - const hasUserReacted = Report.hasAccountIDEmojiReacted(this.props.currentUserPersonalDetails.accountID, reactionUsers); + + const {emojiCodes, reactionCount, hasUserReacted, userAccountIDs} = EmojiUtils.getEmojiReactionDetails(emojiName, selectedReaction, this.props.currentUserPersonalDetails.accountID); + const users = PersonalDetailsUtils.getPersonalDetailsByIDs(userAccountIDs, this.props.currentUserPersonalDetails.accountID, true); return { emojiName, - emojiCount, emojiCodes, + reactionCount, hasUserReacted, users, }; @@ -205,7 +201,7 @@ class BasePopoverReactionList extends React.Component { render() { const selectedReaction = this.state.isPopoverVisible ? lodashGet(this.props.emojiReactions, [this.props.emojiName]) : null; - const {emojiName, emojiCount, emojiCodes, hasUserReacted, users} = this.getReactionInformation(selectedReaction); + const {emojiName, emojiCodes, reactionCount, hasUserReacted, users} = this.getReactionInformation(selectedReaction, this.props.emojiName); return (