Skip to content

Commit

Permalink
Merge pull request #17996 from Expensify/tgolen-migrate-reactions
Browse files Browse the repository at this point in the history
Support new emojiReaction data format
  • Loading branch information
MariaHCD committed Jul 20, 2023
2 parents cc5526b + 73ca137 commit e87f2eb
Show file tree
Hide file tree
Showing 13 changed files with 325 additions and 271 deletions.
1 change: 1 addition & 0 deletions src/CONST.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
6 changes: 5 additions & 1 deletion src/components/AttachmentCarousel/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}) {
Expand Down
3 changes: 2 additions & 1 deletion src/components/AttachmentPicker/index.native.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions src/components/Reactions/EmojiReactionsPropTypes.js
Original file line number Diff line number Diff line change
@@ -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),
}),
),
}),
);
13 changes: 10 additions & 3 deletions src/components/Reactions/MiniQuickEmojiReactions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -40,6 +40,7 @@ const propTypes = {
};

const defaultProps = {
...baseQuickEmojiReactionsDefaultProps,
onEmojiPickerClosed: () => {},
preferredSkinTone: CONST.EMOJI_DEFAULT_SKIN_TONE,
reportAction: {},
Expand All @@ -61,7 +62,7 @@ function MiniQuickEmojiReactions(props) {
EmojiPickerAction.showEmojiPicker(
props.onEmojiPickerClosed,
(emojiCode, emojiObject) => {
props.onEmojiSelected(emojiObject);
props.onEmojiSelected(emojiObject, props.emojiReactions);
},
ref.current,
undefined,
Expand All @@ -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))}
>
<Text style={[styles.miniQuickEmojiReactionText, styles.userSelectNone]}>{EmojiUtils.getPreferredEmojiCode(emoji, props.preferredSkinTone)}</Text>
</BaseMiniContextMenuItem>
Expand Down Expand Up @@ -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);
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -39,6 +42,7 @@ const baseQuickEmojiReactionsPropTypes = {
};

const baseQuickEmojiReactionsDefaultProps = {
emojiReactions: {},
onWillShowPicker: () => {},
onPressOpenPicker: () => {},
reportAction: {},
Expand Down Expand Up @@ -67,7 +71,7 @@ function BaseQuickEmojiReactions(props) {
<EmojiReactionBubble
emojiCodes={[EmojiUtils.getPreferredEmojiCode(emoji, props.preferredSkinTone)]}
isContextMenu
onPress={Session.checkIfActionIsAllowed(() => props.onEmojiSelected(emoji))}
onPress={Session.checkIfActionIsAllowed(() => props.onEmojiSelected(emoji, props.emojiReactions))}
/>
</View>
</Tooltip>
Expand All @@ -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};
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 (
<View
ref={popoverReactionListAnchor}
style={[styles.flexRow, styles.flexWrap, styles.gap1, styles.mt2]}
>
{_.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) => {
Expand All @@ -76,14 +103,14 @@ function ReportActionItemReactions(props) {
<Tooltip
renderTooltipContent={() => (
<ReactionTooltipContent
emojiName={EmojiUtils.getLocalizedEmojiName(reaction.emoji, props.preferredLocale)}
emojiName={EmojiUtils.getLocalizedEmojiName(reactionEmojiName, props.preferredLocale)}
emojiCodes={emojiCodes}
accountIDs={reactionUsers}
accountIDs={reactionUserAccountIDs}
currentUserPersonalDetails={props.currentUserPersonalDetails}
/>
)}
renderTooltipContentKey={[..._.map(reactionUsers, (user) => user.toString()), ...emojiCodes]}
key={reaction.emoji}
key={reactionEmojiName}
>
<View>
<EmojiReactionBubble
Expand All @@ -99,7 +126,7 @@ function ReportActionItemReactions(props) {
</Tooltip>
);
})}
{reactionsWithCount.length > 0 && (
{totalReactionCount > 0 && (
<AddReactionBubble
onSelectEmoji={props.toggleReaction}
reportAction={{reportActionID: props.reportActionID}}
Expand All @@ -109,7 +136,7 @@ function ReportActionItemReactions(props) {
);
}

ReportActionItemReactions.displayName = 'ReportActionItemReactions';
ReportActionItemReactions.propTypes = propTypes;
ReportActionItemReactions.defaultProps = defaultProps;
export default compose(withLocalize, withCurrentUserPersonalDetails)(ReportActionItemReactions);
ReportActionItemEmojiReactions.displayName = 'ReportActionItemReactions';
ReportActionItemEmojiReactions.propTypes = propTypes;
ReportActionItemEmojiReactions.defaultProps = defaultProps;
export default compose(withLocalize, withCurrentUserPersonalDetails)(ReportActionItemEmojiReactions);
4 changes: 3 additions & 1 deletion src/components/ThumbnailImage.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,9 @@ class ThumbnailImage extends PureComponent {
/**
* Update the state with the computed thumbnail sizes.
*
* @param {{ width: number, height: number }} Params - width and height of the original image.
* @param {Object} Params - width and height of the original image.
* @param {Number} Params.width
* @param {Number} Params.height
*/
updateImageSize({width, height}) {
const {thumbnailWidth, thumbnailHeight} = this.calculateThumbnailImageSize(width, height);
Expand Down
25 changes: 15 additions & 10 deletions src/libs/EmojiUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import _ from 'underscore';
import moment from 'moment';
import Str from 'expensify-common/lib/str';
import Onyx from 'react-native-onyx';
import lodashGet from 'lodash/get';
import ONYXKEYS from '../ONYXKEYS';
import CONST from '../CONST';
import emojisTrie from './EmojiTrie';
Expand Down Expand Up @@ -384,20 +385,24 @@ 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.
* @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 {
Expand Down
Loading

0 comments on commit e87f2eb

Please sign in to comment.