Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 53 additions & 9 deletions packages/react/src/lib/reaction.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,61 @@
export const serializeReactions = (reactions) => {
if (!reactions || typeof reactions !== 'object') {
return [];
}
const arr = [];

const emojis = Object.keys(reactions);
emojis.forEach((emoji) =>
arr.push({
name: emoji,
count: reactions[emoji].usernames.length,
usernames: reactions[emoji].usernames,
})
);
emojis.forEach((emoji) => {
const reaction = reactions[emoji];
if (reaction && Array.isArray(reaction.usernames)) {
arr.push({
name: emoji,
count: reaction.usernames.length,
usernames: reaction.usernames,
names: Array.isArray(reaction.names) ? reaction.names : [],
});
}
});

return arr;
};

export const isSameUser = (reaction, username) =>
reaction.usernames.find((u) => u === username);
Boolean(reaction?.usernames?.find((u) => u === username));

export const getReactorsList = (reactions, targetEmoji = null) => {
if (!reactions || typeof reactions !== 'object') {
return [];
}

const result = [];
const emojis =
targetEmoji && targetEmoji !== 'all'
? [targetEmoji]
: Object.keys(reactions);

emojis.forEach((emoji) => {
const data = reactions[emoji];
if (data && Array.isArray(data.usernames)) {
data.usernames.forEach((username, idx) => {
const name = (data.names && data.names[idx]) || username;
result.push({
username,
name,
emoji,
});
});
}
});

return result;
};

export const getTotalReactionCount = (reactions) => {
if (!reactions || typeof reactions !== 'object') {
return 0;
}
return Object.values(reactions).reduce(
(sum, r) => sum + (Array.isArray(r?.usernames) ? r.usernames.length : 0),
0
);
};
85 changes: 85 additions & 0 deletions packages/react/src/lib/reaction.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
serializeReactions,
isSameUser,
getReactorsList,
getTotalReactionCount,
} from './reaction.js';

const mockReactions = {
':thumbsup:': {
usernames: ['alice', 'bob'],
names: ['Alice Smith', 'Bob Jones'],
},
':heart:': {
usernames: ['charlie'],
},
};

test('serializeReactions properly transforms reactions object', () => {
const result = serializeReactions(mockReactions);
assert.equal(result.length, 2);
assert.equal(result[0].name, ':thumbsup:');
assert.equal(result[0].count, 2);
assert.deepEqual(result[0].usernames, ['alice', 'bob']);
assert.deepEqual(result[0].names, ['Alice Smith', 'Bob Jones']);

assert.equal(result[1].name, ':heart:');
assert.equal(result[1].count, 1);
assert.deepEqual(result[1].usernames, ['charlie']);
assert.deepEqual(result[1].names, []);
});

test('serializeReactions handles null or invalid input safely', () => {
assert.deepEqual(serializeReactions(null), []);
assert.deepEqual(serializeReactions(undefined), []);
assert.deepEqual(serializeReactions('invalid'), []);
});

test('isSameUser checks if a user reacted', () => {
const reaction = { usernames: ['alice', 'bob'] };
assert.equal(isSameUser(reaction, 'alice'), true);
assert.equal(isSameUser(reaction, 'bob'), true);
assert.equal(isSameUser(reaction, 'eve'), false);
assert.equal(isSameUser(null, 'alice'), false);
});

test('getReactorsList returns reactors for a specific emoji', () => {
const reactors = getReactorsList(mockReactions, ':thumbsup:');
assert.equal(reactors.length, 2);
assert.deepEqual(reactors[0], {
username: 'alice',
name: 'Alice Smith',
emoji: ':thumbsup:',
});
assert.deepEqual(reactors[1], {
username: 'bob',
name: 'Bob Jones',
emoji: ':thumbsup:',
});
});

test('getReactorsList fallbacks to username when name is not provided', () => {
const reactors = getReactorsList(mockReactions, ':heart:');
assert.equal(reactors.length, 1);
assert.deepEqual(reactors[0], {
username: 'charlie',
name: 'charlie',
emoji: ':heart:',
});
});

test('getReactorsList aggregates all emojis when target is null or all', () => {
const allReactors = getReactorsList(mockReactions);
assert.equal(allReactors.length, 3);

const allReactorsExplicit = getReactorsList(mockReactions, 'all');
assert.equal(allReactorsExplicit.length, 3);
});

test('getTotalReactionCount calculates correct total', () => {
assert.equal(getTotalReactionCount(mockReactions), 3);
assert.equal(getTotalReactionCount(null), 0);
assert.equal(getTotalReactionCount({}), 0);
});
112 changes: 75 additions & 37 deletions packages/react/src/views/Message/MessageReactions.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React, { useState, useMemo } from 'react';
import PropTypes from 'prop-types';
import {
Box,
useComponentOverrides,
Expand All @@ -8,6 +9,7 @@ import {
import { Markdown } from '../Markdown';
import { isSameUser, serializeReactions } from '../../lib/reaction';
import { getMessageReactionsStyles } from './Message.styles';
import ReactionDetailsModal from '../ReactionDetailsModal/ReactionDetailsModal';

export const MessageReactions = ({
message,
Expand All @@ -25,6 +27,18 @@ export const MessageReactions = ({
const { theme } = useTheme();
const styles = getMessageReactionsStyles(theme);
const [hoveredReaction, setHoveredReaction] = useState(null);
const [selectedEmoji, setSelectedEmoji] = useState(null);
const [isReactionModalOpen, setIsReactionModalOpen] = useState(false);

const handleOpenReactionModal = (emojiName) => {
setSelectedEmoji(emojiName);
setIsReactionModalOpen(true);
};

const handleCloseReactionModal = () => {
setIsReactionModalOpen(false);
setSelectedEmoji(null);
};

const tooltipMap = useMemo(() => {
const map = {};
Expand Down Expand Up @@ -63,42 +77,66 @@ export const MessageReactions = ({
}, [message.reactions, authenticatedUserUsername]);

return (
<Box
css={styles.container}
className={appendClassNames('ec-message-reactions', classNames)}
style={styleOverrides}
{...props}
>
{message.reactions &&
serializeReactions(message.reactions).map((reaction) => {
const isUserReaction = isSameUser(
reaction,
authenticatedUserUsername
);
return (
<Box
key={reaction.name}
css={
isUserReaction
? [styles.reaction, styles.reactionMine]
: [styles.reaction]
}
mine={isUserReaction}
onClick={() =>
handleEmojiClick(reaction, message, !isUserReaction)
}
onMouseEnter={() => setHoveredReaction(reaction.name)}
onMouseLeave={() => setHoveredReaction(null)}
style={{ position: 'relative' }}
>
<Markdown body={reaction.name} isReaction />
<p>{reaction.count}</p>
{hoveredReaction === reaction.name && (
<Box css={styles.emojiTooltip}>{tooltipMap[reaction.name]}</Box>
)}
</Box>
);
})}
</Box>
<>
<Box
css={styles.container}
className={appendClassNames('ec-message-reactions', classNames)}
style={styleOverrides}
{...props}
>
{message.reactions &&
serializeReactions(message.reactions).map((reaction) => {
const isUserReaction = isSameUser(
reaction,
authenticatedUserUsername
);
return (
<Box
key={reaction.name}
css={
isUserReaction
? [styles.reaction, styles.reactionMine]
: [styles.reaction]
}
mine={isUserReaction}
onClick={() => handleOpenReactionModal(reaction.name)}
onContextMenu={(e) => {
e.preventDefault();
handleOpenReactionModal(reaction.name);
}}
onMouseEnter={() => setHoveredReaction(reaction.name)}
onMouseLeave={() => setHoveredReaction(null)}
style={{ position: 'relative' }}
>
<Markdown body={reaction.name} isReaction />
<p>{reaction.count}</p>
{hoveredReaction === reaction.name && (
<Box css={styles.emojiTooltip}>
{tooltipMap[reaction.name]}
</Box>
)}
</Box>
);
})}
</Box>

{isReactionModalOpen && (
<ReactionDetailsModal
message={message}
initialEmoji={selectedEmoji}
authenticatedUserUsername={authenticatedUserUsername}
handleEmojiClick={handleEmojiClick}
onClose={handleCloseReactionModal}
/>
)}
</>
);
};

MessageReactions.propTypes = {
message: PropTypes.object.isRequired,
authenticatedUserUsername: PropTypes.string,
handleEmojiClick: PropTypes.func,
className: PropTypes.string,
style: PropTypes.object,
};
23 changes: 22 additions & 1 deletion packages/react/src/views/Message/MessageToolbox.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import { getMessageToolboxStyles } from './Message.styles';
import SurfaceMenu from '../SurfaceMenu/SurfaceMenu';
import { Markdown } from '../Markdown';
import Attachment from '../AttachmentHandler/Attachment';
import { useUserStore } from '../../store';
import ReactionDetailsModal from '../ReactionDetailsModal/ReactionDetailsModal';

export const MessageToolbox = ({
className = '',
Expand Down Expand Up @@ -74,8 +76,9 @@ export const MessageToolbox = ({
configOverrides.optionConfig?.menuItems || optionConfig.menuItems;

const [isEmojiOpen, setEmojiOpen] = useState(false);

const [showDeleteModal, setShowDeleteModal] = useState(false);
const [showReactionModal, setShowReactionModal] = useState(false);
const authenticatedUserUsername = useUserStore((state) => state.username);

const handleOnClose = () => {
setShowDeleteModal(false);
Expand Down Expand Up @@ -151,6 +154,15 @@ export const MessageToolbox = ({
iconName: 'emoji',
visible: true,
},
reactions: {
label: 'Reactions',
id: 'reactions',
onClick: () => setShowReactionModal(true),
iconName: 'emoji',
visible: Boolean(
message.reactions && Object.keys(message.reactions).length > 0
),
},
pin: {
label: message.pinned ? 'Unpin' : 'Pin',
id: 'pin',
Expand Down Expand Up @@ -380,6 +392,15 @@ export const MessageToolbox = ({
</Modal.Footer>
</Modal>
)}

{showReactionModal && (
<ReactionDetailsModal
message={message}
authenticatedUserUsername={authenticatedUserUsername}
handleEmojiClick={handleEmojiClick}
onClose={() => setShowReactionModal(false)}
/>
)}
</>
);
};
Loading