From 7190fa3173bf21c9252adb128fbded2897ec5aff Mon Sep 17 00:00:00 2001 From: Tisya Gupta Date: Thu, 15 Oct 2026 12:16:35 +0530 Subject: [PATCH] feat(react): add reaction details modal to view reactors per emoji (#1369) --- packages/react/src/lib/reaction.js | 62 ++++- packages/react/src/lib/reaction.test.js | 85 +++++++ .../src/views/Message/MessageReactions.js | 112 ++++++--- .../react/src/views/Message/MessageToolbox.js | 23 +- .../ReactionDetailsModal.js | 217 ++++++++++++++++++ .../ReactionDetailsModal.styles.js | 139 +++++++++++ .../ReactionDetailsModal.test.js | 108 +++++++++ .../src/views/ReactionDetailsModal/index.js | 1 + 8 files changed, 700 insertions(+), 47 deletions(-) create mode 100644 packages/react/src/lib/reaction.test.js create mode 100644 packages/react/src/views/ReactionDetailsModal/ReactionDetailsModal.js create mode 100644 packages/react/src/views/ReactionDetailsModal/ReactionDetailsModal.styles.js create mode 100644 packages/react/src/views/ReactionDetailsModal/ReactionDetailsModal.test.js create mode 100644 packages/react/src/views/ReactionDetailsModal/index.js diff --git a/packages/react/src/lib/reaction.js b/packages/react/src/lib/reaction.js index 8b0fb485d6..1ad50264e7 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 0000000000..b70277193e --- /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 39a0cc6f96..2a6b3169da 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 e530a458a1..937e7d8927 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 0000000000..8a42c51719 --- /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 0000000000..03821f7126 --- /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 0000000000..57e37a20fc --- /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 0000000000..4d5ccc8a98 --- /dev/null +++ b/packages/react/src/views/ReactionDetailsModal/index.js @@ -0,0 +1 @@ +export { default as ReactionDetailsModal } from './ReactionDetailsModal';