Skip to content

Commit

Permalink
Merge pull request #29846 from paultsimura/fix/27961-emoji-ordering
Browse files Browse the repository at this point in the history
fix: Chat - Emoji reactions are not being displayed in order of who reacted first
  • Loading branch information
lakchote authored Oct 20, 2023
2 parents 86e75ad + 618739d commit 69119aa
Show file tree
Hide file tree
Showing 5 changed files with 168 additions and 125 deletions.
107 changes: 38 additions & 69 deletions src/components/Reactions/ReportActionItemEmojiReactions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 && (
Expand All @@ -131,11 +101,11 @@ function ReportActionItemEmojiReactions(props) {
<ReactionTooltipContent
emojiName={EmojiUtils.getLocalizedEmojiName(reaction.reactionEmojiName, props.preferredLocale)}
emojiCodes={reaction.emojiCodes}
accountIDs={reaction.reactionUserAccountIDs}
accountIDs={reaction.userAccountIDs}
currentUserPersonalDetails={props.currentUserPersonalDetails}
/>
)}
renderTooltipContentKey={[..._.map(reaction.reactionUsers, (user) => user.toString()), ...reaction.emojiCodes]}
renderTooltipContentKey={[..._.map(reaction.userAccountIDs, String), ...reaction.emojiCodes]}
key={reaction.reactionEmojiName}
>
<View>
Expand All @@ -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}
Expand Down
120 changes: 109 additions & 11 deletions src/libs/EmojiUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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<Object | String | number>} 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 {
Expand All @@ -511,8 +607,10 @@ export {
getPreferredSkinToneIndex,
getPreferredEmojiCode,
getUniqueEmojiCodes,
getEmojiReactionDetails,
replaceAndExtractEmojis,
extractEmojis,
getAddedEmojis,
isFirstLetterEmoji,
hasAccountIDEmojiReacted,
};
22 changes: 11 additions & 11 deletions src/libs/PersonalDetailsUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

/**
Expand Down
22 changes: 1 addition & 21 deletions src/libs/actions/Report.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<Object | String | number>} 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"
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -2452,7 +2433,6 @@ export {
notifyNewAction,
showReportActionNotification,
toggleEmojiReaction,
hasAccountIDEmojiReacted,
shouldShowReportActionNotification,
leaveRoom,
inviteToRoom,
Expand Down
Loading

0 comments on commit 69119aa

Please sign in to comment.