diff --git a/packages/react/src/lib/reaction.js b/packages/react/src/lib/reaction.js
index 8b0fb485d..1ad50264e 100644
--- a/packages/react/src/lib/reaction.js
+++ b/packages/react/src/lib/reaction.js
@@ -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
+ );
+};
diff --git a/packages/react/src/lib/reaction.test.js b/packages/react/src/lib/reaction.test.js
new file mode 100644
index 000000000..b70277193
--- /dev/null
+++ b/packages/react/src/lib/reaction.test.js
@@ -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);
+});
diff --git a/packages/react/src/views/Message/MessageReactions.js b/packages/react/src/views/Message/MessageReactions.js
index 39a0cc6f9..2a6b3169d 100644
--- a/packages/react/src/views/Message/MessageReactions.js
+++ b/packages/react/src/views/Message/MessageReactions.js
@@ -1,4 +1,5 @@
import React, { useState, useMemo } from 'react';
+import PropTypes from 'prop-types';
import {
Box,
useComponentOverrides,
@@ -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,
@@ -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 = {};
@@ -63,42 +77,66 @@ export const MessageReactions = ({
}, [message.reactions, authenticatedUserUsername]);
return (
-
- {message.reactions &&
- serializeReactions(message.reactions).map((reaction) => {
- const isUserReaction = isSameUser(
- reaction,
- authenticatedUserUsername
- );
- return (
-
- handleEmojiClick(reaction, message, !isUserReaction)
- }
- onMouseEnter={() => setHoveredReaction(reaction.name)}
- onMouseLeave={() => setHoveredReaction(null)}
- style={{ position: 'relative' }}
- >
-
- {reaction.count}
- {hoveredReaction === reaction.name && (
- {tooltipMap[reaction.name]}
- )}
-
- );
- })}
-
+ <>
+
+ {message.reactions &&
+ serializeReactions(message.reactions).map((reaction) => {
+ const isUserReaction = isSameUser(
+ reaction,
+ authenticatedUserUsername
+ );
+ return (
+ handleOpenReactionModal(reaction.name)}
+ onContextMenu={(e) => {
+ e.preventDefault();
+ handleOpenReactionModal(reaction.name);
+ }}
+ onMouseEnter={() => setHoveredReaction(reaction.name)}
+ onMouseLeave={() => setHoveredReaction(null)}
+ style={{ position: 'relative' }}
+ >
+
+ {reaction.count}
+ {hoveredReaction === reaction.name && (
+
+ {tooltipMap[reaction.name]}
+
+ )}
+
+ );
+ })}
+
+
+ {isReactionModalOpen && (
+
+ )}
+ >
);
};
+
+MessageReactions.propTypes = {
+ message: PropTypes.object.isRequired,
+ authenticatedUserUsername: PropTypes.string,
+ handleEmojiClick: PropTypes.func,
+ className: PropTypes.string,
+ style: PropTypes.object,
+};
diff --git a/packages/react/src/views/Message/MessageToolbox.js b/packages/react/src/views/Message/MessageToolbox.js
index e530a458a..937e7d892 100644
--- a/packages/react/src/views/Message/MessageToolbox.js
+++ b/packages/react/src/views/Message/MessageToolbox.js
@@ -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 = '',
@@ -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);
@@ -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',
@@ -380,6 +392,15 @@ export const MessageToolbox = ({
)}
+
+ {showReactionModal && (
+ setShowReactionModal(false)}
+ />
+ )}
>
);
};
diff --git a/packages/react/src/views/ReactionDetailsModal/ReactionDetailsModal.js b/packages/react/src/views/ReactionDetailsModal/ReactionDetailsModal.js
new file mode 100644
index 000000000..8a42c5171
--- /dev/null
+++ b/packages/react/src/views/ReactionDetailsModal/ReactionDetailsModal.js
@@ -0,0 +1,217 @@
+import React, { useState, useMemo, useContext } from 'react';
+import PropTypes from 'prop-types';
+import {
+ Modal,
+ Box,
+ Avatar,
+ Button,
+ Icon,
+ useTheme,
+ useComponentOverrides,
+ appendClassNames,
+} from '@embeddedchat/ui-elements';
+import { Markdown } from '../Markdown';
+import {
+ serializeReactions,
+ getReactorsList,
+ getTotalReactionCount,
+} from '../../lib/reaction';
+import { getReactionDetailsModalStyles } from './ReactionDetailsModal.styles';
+import RCContext, { useRCContext } from '../../context/RCInstance';
+import useSetExclusiveState from '../../hooks/useSetExclusiveState';
+import { useUserStore } from '../../store';
+
+const ReactionDetailsModal = ({
+ message,
+ initialEmoji = null,
+ authenticatedUserUsername = '',
+ handleEmojiClick = () => {},
+ onClose = () => {},
+ className = '',
+ style = {},
+}) => {
+ const { styleOverrides, classNames } = useComponentOverrides(
+ 'ReactionDetailsModal',
+ className,
+ style
+ );
+ const { theme } = useTheme();
+ const styles = getReactionDetailsModalStyles(theme);
+ const { RCInstance } = useContext(RCContext);
+ const { ECOptions } = useRCContext();
+
+ const serialized = useMemo(
+ () => serializeReactions(message?.reactions),
+ [message?.reactions]
+ );
+
+ const totalCount = useMemo(
+ () => getTotalReactionCount(message?.reactions),
+ [message?.reactions]
+ );
+
+ const [activeTab, setActiveTab] = useState(() => {
+ if (initialEmoji && message?.reactions?.[initialEmoji]) {
+ return initialEmoji;
+ }
+ return serialized[0]?.name || 'all';
+ });
+
+ const reactors = useMemo(
+ () => getReactorsList(message?.reactions, activeTab),
+ [message?.reactions, activeTab]
+ );
+
+ const setExclusiveState = useSetExclusiveState();
+ const { setShowCurrentUserInfo, setCurrentUser } = useUserStore((state) => ({
+ setShowCurrentUserInfo: state.setShowCurrentUserInfo,
+ setCurrentUser: state.setCurrentUser,
+ }));
+
+ const host = RCInstance?.getHost ? RCInstance.getHost() : ECOptions?.host;
+
+ const getUserAvatarUrl = (username) => {
+ if (!host || !username) {
+ return null;
+ }
+ return `${host}/avatar/${username}`;
+ };
+
+ const handleUserClick = (user) => {
+ if (setCurrentUser && setShowCurrentUserInfo) {
+ setExclusiveState(setShowCurrentUserInfo);
+ setCurrentUser(user);
+ onClose();
+ }
+ };
+
+ const activeReaction = serialized.find((r) => r.name === activeTab);
+ const hasUserReactedToActive = Boolean(
+ activeReaction?.usernames?.includes(authenticatedUserUsername)
+ );
+
+ const handleToggleActiveReaction = () => {
+ if (!activeReaction) {
+ return;
+ }
+ handleEmojiClick(activeReaction, message, !hasUserReactedToActive);
+ };
+
+ return (
+
+
+
+
+ Reactions
+
+
+
+
+
+
+
+ {serialized.map((reaction) => (
+
+ ))}
+
+
+
+ {reactors.length === 0 ? (
+ No reactions found
+ ) : (
+ reactors.map((reactor, index) => {
+ const isCurrentUser =
+ reactor.username === authenticatedUserUsername;
+ return (
+
+
+ handleUserClick({
+ username: reactor.username,
+ name: reactor.name,
+ })
+ }
+ >
+
+
+
+ {reactor.name}
+ {isCurrentUser && (
+ You
+ )}
+
+ @{reactor.username}
+
+
+
+
+
+
+
+
+
+ );
+ })
+ )}
+
+
+
+
+ {activeReaction && authenticatedUserUsername && (
+
+ )}
+
+
+
+ );
+};
+
+ReactionDetailsModal.propTypes = {
+ message: PropTypes.object.isRequired,
+ initialEmoji: PropTypes.string,
+ authenticatedUserUsername: PropTypes.string,
+ handleEmojiClick: PropTypes.func,
+ onClose: PropTypes.func.isRequired,
+ className: PropTypes.string,
+ style: PropTypes.object,
+};
+
+export default ReactionDetailsModal;
diff --git a/packages/react/src/views/ReactionDetailsModal/ReactionDetailsModal.styles.js b/packages/react/src/views/ReactionDetailsModal/ReactionDetailsModal.styles.js
new file mode 100644
index 000000000..03821f712
--- /dev/null
+++ b/packages/react/src/views/ReactionDetailsModal/ReactionDetailsModal.styles.js
@@ -0,0 +1,139 @@
+import { css } from '@emotion/react';
+
+export const getReactionDetailsModalStyles = (theme) => ({
+ modalDialog: css`
+ width: 100%;
+ max-width: 400px;
+ box-sizing: border-box;
+ padding: 0;
+ `,
+ headerContent: css`
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ `,
+ tabList: css`
+ display: flex;
+ align-items: center;
+ gap: 0.35rem;
+ overflow-x: auto;
+ padding: 0.4rem 0.6rem 0.6rem;
+ border-bottom: 1px solid ${theme.colors.border};
+ scrollbar-width: thin;
+ &::-webkit-scrollbar {
+ height: 4px;
+ }
+ &::-webkit-scrollbar-thumb {
+ background: ${theme.colors.border};
+ border-radius: 4px;
+ }
+ `,
+ tabButton: (isActive) => css`
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ padding: 0.25rem 0.6rem;
+ border-radius: 9999px;
+ font-size: 0.8rem;
+ font-weight: 500;
+ cursor: pointer;
+ background: ${isActive ? theme.colors.secondary : 'transparent'};
+ color: ${isActive ? theme.colors.foreground : theme.colors.mutedForeground};
+ border: 1px solid ${isActive ? theme.colors.primary : theme.colors.border};
+ transition: all 0.15s ease-in-out;
+ white-space: nowrap;
+ outline: none;
+
+ img.joypixels {
+ height: 1em;
+ width: 1em;
+ }
+
+ &:hover {
+ background: ${theme.colors.secondary};
+ color: ${theme.colors.foreground};
+ }
+ `,
+ reactorsList: css`
+ max-height: 280px;
+ overflow-y: auto;
+ padding: 0.5rem 0.6rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ `,
+ reactorRow: css`
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.4rem 0.5rem;
+ border-radius: ${theme.radius};
+ transition: background 0.15s ease-in-out;
+
+ &:hover {
+ background: ${theme.colors.secondary};
+ }
+ `,
+ userSection: css`
+ display: flex;
+ align-items: center;
+ gap: 0.6rem;
+ min-width: 0;
+ `,
+ userInfo: css`
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ `,
+ nameContainer: css`
+ display: flex;
+ align-items: center;
+ gap: 0.35rem;
+ `,
+ displayName: css`
+ font-weight: 600;
+ font-size: 0.875rem;
+ color: ${theme.colors.foreground};
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ `,
+ username: css`
+ font-size: 0.75rem;
+ color: ${theme.colors.mutedForeground};
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ `,
+ youBadge: css`
+ font-size: 0.65rem;
+ font-weight: 600;
+ padding: 0.1rem 0.35rem;
+ border-radius: 4px;
+ background: ${theme.colors.secondary};
+ color: ${theme.colors.primary};
+ border: 1px solid ${theme.colors.border};
+ `,
+ actionSection: css`
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ flex-shrink: 0;
+ `,
+ emojiBadge: css`
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+
+ img.joypixels {
+ height: 1.1em;
+ width: 1.1em;
+ }
+ `,
+ emptyState: css`
+ text-align: center;
+ padding: 1.5rem 0.5rem;
+ color: ${theme.colors.mutedForeground};
+ font-size: 0.85rem;
+ `,
+});
diff --git a/packages/react/src/views/ReactionDetailsModal/ReactionDetailsModal.test.js b/packages/react/src/views/ReactionDetailsModal/ReactionDetailsModal.test.js
new file mode 100644
index 000000000..57e37a20f
--- /dev/null
+++ b/packages/react/src/views/ReactionDetailsModal/ReactionDetailsModal.test.js
@@ -0,0 +1,108 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {
+ serializeReactions,
+ getReactorsList,
+ getTotalReactionCount,
+} from '../../lib/reaction.js';
+
+const mockMessageWithReactions = {
+ _id: 'msg123',
+ msg: 'Great job everyone!',
+ reactions: {
+ ':thumbsup:': {
+ usernames: ['alice', 'bob'],
+ names: ['Alice Smith', 'Bob Jones'],
+ },
+ ':heart:': {
+ usernames: ['alice', 'charlie'],
+ names: ['Alice Smith'],
+ },
+ ':rocket:': {
+ usernames: ['david'],
+ },
+ },
+};
+
+test('ReactionDetailsModal data: calculates correct tabs and counts', () => {
+ const serialized = serializeReactions(mockMessageWithReactions.reactions);
+ assert.equal(serialized.length, 3);
+
+ const totalCount = getTotalReactionCount(mockMessageWithReactions.reactions);
+ assert.equal(totalCount, 5);
+
+ assert.equal(serialized[0].name, ':thumbsup:');
+ assert.equal(serialized[0].count, 2);
+
+ assert.equal(serialized[1].name, ':heart:');
+ assert.equal(serialized[1].count, 2);
+
+ assert.equal(serialized[2].name, ':rocket:');
+ assert.equal(serialized[2].count, 1);
+});
+
+test('ReactionDetailsModal data: getReactorsList filters by specific emoji', () => {
+ const thumbsUpReactors = getReactorsList(
+ mockMessageWithReactions.reactions,
+ ':thumbsup:'
+ );
+ assert.equal(thumbsUpReactors.length, 2);
+ assert.equal(thumbsUpReactors[0].username, 'alice');
+ assert.equal(thumbsUpReactors[0].name, 'Alice Smith');
+ assert.equal(thumbsUpReactors[0].emoji, ':thumbsup:');
+
+ assert.equal(thumbsUpReactors[1].username, 'bob');
+ assert.equal(thumbsUpReactors[1].name, 'Bob Jones');
+});
+
+test('ReactionDetailsModal data: getReactorsList falls back to username when name is missing', () => {
+ const heartReactors = getReactorsList(
+ mockMessageWithReactions.reactions,
+ ':heart:'
+ );
+ assert.equal(heartReactors.length, 2);
+ assert.equal(heartReactors[0].name, 'Alice Smith');
+ assert.equal(heartReactors[1].username, 'charlie');
+ assert.equal(heartReactors[1].name, 'charlie'); // fallback to username
+});
+
+test('ReactionDetailsModal data: All tab aggregates reactors across all emojis', () => {
+ const allReactors = getReactorsList(
+ mockMessageWithReactions.reactions,
+ 'all'
+ );
+ assert.equal(allReactors.length, 5);
+
+ const emojis = allReactors.map((r) => r.emoji);
+ assert.deepEqual(emojis, [
+ ':thumbsup:',
+ ':thumbsup:',
+ ':heart:',
+ ':heart:',
+ ':rocket:',
+ ]);
+});
+
+test('ReactionDetailsModal data: handles message without reactions gracefully', () => {
+ const emptyMsg = { _id: 'empty', msg: 'No reactions here' };
+ const serialized = serializeReactions(emptyMsg.reactions);
+ assert.deepEqual(serialized, []);
+
+ const totalCount = getTotalReactionCount(emptyMsg.reactions);
+ assert.equal(totalCount, 0);
+
+ const reactors = getReactorsList(emptyMsg.reactions, 'all');
+ assert.deepEqual(reactors, []);
+});
+
+test('ReactionDetailsModal data: identifies authenticated user in reactors list', () => {
+ const authUsername = 'alice';
+ const reactors = getReactorsList(
+ mockMessageWithReactions.reactions,
+ ':thumbsup:'
+ );
+ const isAliceFirst = reactors[0].username === authUsername;
+ const isBobFirst = reactors[1].username === authUsername;
+ assert.equal(isAliceFirst, true);
+ assert.equal(isBobFirst, false);
+});
diff --git a/packages/react/src/views/ReactionDetailsModal/index.js b/packages/react/src/views/ReactionDetailsModal/index.js
new file mode 100644
index 000000000..4d5ccc8a9
--- /dev/null
+++ b/packages/react/src/views/ReactionDetailsModal/index.js
@@ -0,0 +1 @@
+export { default as ReactionDetailsModal } from './ReactionDetailsModal';