feat(chat) add message reactions

pull/15145/head jitsi-meet_9748
Patrick He 8 months ago committed by GitHub
parent acc46c0c5f
commit 7bb2f1eaad
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 1
      lang/main.json
  2. 1
      react/features/base/conference/reducer.ts
  3. 24
      react/features/chat/actionTypes.ts
  4. 41
      react/features/chat/actions.any.ts
  5. 318
      react/features/chat/components/web/ChatMessage.tsx
  6. 1
      react/features/chat/components/web/ChatMessageGroup.tsx
  7. 60
      react/features/chat/components/web/EmojiSelector.tsx
  8. 164
      react/features/chat/components/web/MessageMenu.tsx
  9. 87
      react/features/chat/components/web/ReactButton.tsx
  10. 56
      react/features/chat/middleware.ts
  11. 36
      react/features/chat/reducer.ts
  12. 6
      react/features/chat/types.ts

@ -1260,6 +1260,7 @@
"privateMessage": "Send private message",
"profile": "Edit your profile",
"raiseHand": "Raise your hand",
"react": "Message reactions",
"reactions": "Reactions",
"reactionsMenu": "Reactions menu",
"recording": "Toggle recording",

@ -131,6 +131,7 @@ export interface IJitsiConference {
sendLobbyMessage: Function;
sendMessage: Function;
sendPrivateTextMessage: Function;
sendReaction: Function;
sendTextMessage: Function;
sendTones: Function;
sessionId: string;

@ -13,6 +13,18 @@
*/
export const ADD_MESSAGE = 'ADD_MESSAGE';
/**
* The type of the action that adds a reaction to a chat message.
*
* {
* type: ADD_MESSAGE_REACTION,
* reaction: string,
* messageID: string,
* receiverID: string,
* }
*/
export const ADD_MESSAGE_REACTION = 'ADD_MESSAGE_REACTION';
/**
* The type of the action which signals to clear messages in Redux.
*
@ -62,6 +74,18 @@ export const OPEN_CHAT = 'OPEN_CHAT';
*/
export const SEND_MESSAGE = 'SEND_MESSAGE';
/**
* The type of the action which signals a reaction to a message.
*
* {
* type: SEND_REACTION,
* reaction: string,
* messageID: string,
* receiverID: string
* }
*/
export const SEND_REACTION = 'SEND_REACTION';
/**
* The type of action which signals the initiation of sending of as private message to the
* supplied recipient.

@ -6,11 +6,13 @@ import { LOBBY_CHAT_INITIALIZED } from '../lobby/constants';
import {
ADD_MESSAGE,
ADD_MESSAGE_REACTION,
CLEAR_MESSAGES,
CLOSE_CHAT,
EDIT_MESSAGE,
REMOVE_LOBBY_CHAT_PARTICIPANT,
SEND_MESSAGE,
SEND_REACTION,
SET_IS_POLL_TAB_FOCUSED,
SET_LOBBY_CHAT_ACTIVE_STATE,
SET_LOBBY_CHAT_RECIPIENT,
@ -49,6 +51,27 @@ export function addMessage(messageDetails: Object) {
};
}
/**
* Adds a reaction to a chat message.
*
* @param {Object} reactionDetails - The reaction to add.
* @param {string} reactionDetails.participantId - The ID of the message to react to.
* @param {string} reactionDetails.reactionList - The reaction to add.
* @param {string} reactionDetails.messageId - The receiver ID of the reaction.
* @returns {{
* type: ADD_MESSAGE_REACTION,
* participantId: string,
* reactionList: string[],
* messageId: string
* }}
*/
export function addMessageReaction(reactionDetails: Object) {
return {
type: ADD_MESSAGE_REACTION,
...reactionDetails
};
}
/**
* Edits an existing chat message.
*
@ -111,6 +134,24 @@ export function sendMessage(message: string, ignorePrivacy = false) {
};
}
/**
* Sends a reaction to a message.
*
* @param {string} reaction - The reaction to send.
* @param {string} messageId - The message ID to react to.
* @param {string} receiverId - The receiver ID of the reaction.
* @returns {Function}
*/
export function sendReaction(reaction: string, messageId: string, receiverId?: string) {
return {
type: SEND_REACTION,
reaction,
messageId,
receiverId
};
}
/**
* Initiates the sending of a private message to the supplied participant.
*

@ -1,28 +1,43 @@
import { Theme } from '@mui/material';
import React from 'react';
import React, { useCallback, useState } from 'react';
import { connect } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { IReduxState } from '../../../app/types';
import { translate } from '../../../base/i18n/functions';
import { getParticipantDisplayName } from '../../../base/participants/functions';
import Popover from '../../../base/popover/components/Popover.web';
import Message from '../../../base/react/components/web/Message';
import { withPixelLineHeight } from '../../../base/styles/functions.web';
import { getCanReplyToMessage, getFormattedTimestamp, getMessageText, getPrivateNoticeMessage } from '../../functions';
import { getFormattedTimestamp, getMessageText, getPrivateNoticeMessage } from '../../functions';
import { IChatMessageProps } from '../../types';
import PrivateMessageButton from './PrivateMessageButton';
import MessageMenu from './MessageMenu';
import ReactButton from './ReactButton';
interface IProps extends IChatMessageProps {
shouldDisplayChatMessageMenu: boolean;
state?: IReduxState;
type: string;
}
const useStyles = makeStyles()((theme: Theme) => {
return {
chatMessageFooter: {
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: theme.spacing(1)
},
chatMessageFooterLeft: {
display: 'flex',
flexGrow: 1,
overflow: 'hidden'
},
chatMessageWrapper: {
maxWidth: '100%'
},
chatMessage: {
display: 'inline-flex',
padding: '12px',
@ -35,96 +50,173 @@ const useStyles = makeStyles()((theme: Theme) => {
'&.privatemessage': {
backgroundColor: theme.palette.support05
},
'&.local': {
backgroundColor: theme.palette.ui04,
borderRadius: '12px 4px 12px 12px',
'&.privatemessage': {
backgroundColor: theme.palette.support05
},
'&.local': {
backgroundColor: theme.palette.ui04,
borderRadius: '12px 4px 12px 12px',
'&.privatemessage': {
backgroundColor: theme.palette.support05
}
},
'&.error': {
backgroundColor: theme.palette.actionDanger,
borderRadius: 0,
fontWeight: 100
},
'&.lobbymessage': {
backgroundColor: theme.palette.support05
}
},
'&.error': {
backgroundColor: theme.palette.actionDanger,
borderRadius: 0,
fontWeight: 100
},
'&.lobbymessage': {
backgroundColor: theme.palette.support05
}
},
sideBySideContainer: {
display: 'flex',
flexDirection: 'row',
justifyContent: 'left',
alignItems: 'center',
marginLeft: theme.spacing(1)
},
reactionBox: {
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
backgroundColor: theme.palette.grey[800],
borderRadius: theme.shape.borderRadius,
padding: theme.spacing(0, 1),
cursor: 'pointer'
},
reactionCount: {
fontSize: '0.8rem',
color: theme.palette.grey[400]
},
replyButton: {
padding: '2px'
},
replyWrapper: {
display: 'flex',
flexDirection: 'row' as const,
alignItems: 'center',
maxWidth: '100%'
},
messageContent: {
maxWidth: '100%',
overflow: 'hidden',
flex: 1
},
replyButtonContainer: {
optionsButtonContainer: {
display: 'flex',
alignItems: 'flex-start',
height: '100%'
},
replyButton: {
padding: '2px'
flexDirection: 'column',
alignItems: 'center',
gap: theme.spacing(1),
minWidth: '32px',
minHeight: '32px'
},
displayName: {
...withPixelLineHeight(theme.typography.labelBold),
color: theme.palette.text02,
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
overflow: 'hidden',
marginBottom: theme.spacing(1)
marginBottom: theme.spacing(1),
maxWidth: '130px'
},
userMessage: {
...withPixelLineHeight(theme.typography.bodyShortRegular),
color: theme.palette.text01,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word'
},
privateMessageNotice: {
...withPixelLineHeight(theme.typography.labelRegular),
color: theme.palette.text02,
marginTop: theme.spacing(1)
},
timestamp: {
...withPixelLineHeight(theme.typography.labelRegular),
color: theme.palette.text03,
marginTop: theme.spacing(1)
marginTop: theme.spacing(1),
marginLeft: theme.spacing(1),
whiteSpace: 'nowrap',
flexShrink: 0
},
reactionsPopover: {
padding: theme.spacing(2),
backgroundColor: theme.palette.ui03,
borderRadius: theme.shape.borderRadius,
maxWidth: '150px',
maxHeight: '400px',
overflowY: 'auto',
color: theme.palette.text01
},
reactionItem: {
display: 'flex',
alignItems: 'center',
marginBottom: theme.spacing(1),
gap: theme.spacing(1),
borderBottom: `1px solid ${theme.palette.common.white}`,
paddingBottom: theme.spacing(1),
'&:last-child': {
borderBottom: 'none',
paddingBottom: 0
}
},
participantList: {
marginLeft: theme.spacing(1),
fontSize: '0.8rem',
maxWidth: '120px'
},
participant: {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}
};
});
/**
* Renders a single chat message.
*
* @param {IProps} props - Component's props.
* @returns {JSX}
*/
const ChatMessage = ({
canReply,
knocking,
message,
state,
showDisplayName,
showTimestamp,
type,
shouldDisplayChatMessageMenu,
knocking,
t
}: IProps) => {
const { classes, cx } = useStyles();
const [ isHovered, setIsHovered ] = useState(false);
const [ isReactionsOpen, setIsReactionsOpen ] = useState(false);
const handleMouseEnter = useCallback(() => {
setIsHovered(true);
}, []);
const handleMouseLeave = useCallback(() => {
setIsHovered(false);
}, []);
const handleReactionsOpen = useCallback(() => {
setIsReactionsOpen(true);
}, []);
const handleReactionsClose = useCallback(() => {
setIsReactionsOpen(false);
}, []);
/**
* Renders the display name of the sender.
@ -167,42 +259,144 @@ const ChatMessage = ({
);
}
/**
* Renders the reactions for the message.
*
* @returns {React$Element<*>}
*/
const renderReactions = () => {
if (!message.reactions || message.reactions.size === 0) {
return null;
}
const reactionsArray = Array.from(message.reactions.entries())
.map(([ reaction, participants ]) => {
return { reaction,
participants };
})
.sort((a, b) => b.participants.size - a.participants.size);
const totalReactions = reactionsArray.reduce((sum, { participants }) => sum + participants.size, 0);
const numReactionsDisplayed = 3;
const reactionsContent = (
<div className = { classes.reactionsPopover }>
{reactionsArray.map(({ reaction, participants }) => (
<div
className = { classes.reactionItem }
key = { reaction }>
<span>{reaction}</span>
<span>{participants.size}</span>
<div className = { classes.participantList }>
{Array.from(participants).map(participantId => (
<div
className = { classes.participant }
key = { participantId }>
{state && getParticipantDisplayName(state, participantId)}
</div>
))}
</div>
</div>
))}
</div>
);
return (
<Popover
content = { reactionsContent }
onPopoverClose = { handleReactionsClose }
onPopoverOpen = { handleReactionsOpen }
position = 'top'
trigger = 'hover'
visible = { isReactionsOpen }>
<div className = { classes.reactionBox }>
{reactionsArray.slice(0, numReactionsDisplayed).map(({ reaction }, index) =>
<span key = { index }>{reaction}</span>
)}
{reactionsArray.length > numReactionsDisplayed && (
<span className = { classes.reactionCount }>
+{totalReactions - numReactionsDisplayed}
</span>
)}
</div>
</Popover>
);
};
return (
<div
className = { cx(classes.chatMessageWrapper, type) }
id = { message.messageId }
onMouseEnter = { handleMouseEnter }
onMouseLeave = { handleMouseLeave }
tabIndex = { -1 }>
<div
className = { cx('chatmessage', classes.chatMessage, type,
message.privateMessage && 'privatemessage',
message.lobbyChat && !knocking && 'lobbymessage') }>
<div className = { classes.replyWrapper }>
<div className = { cx('messagecontent', classes.messageContent) }>
{showDisplayName && _renderDisplayName()}
<div className = { cx('usermessage', classes.userMessage) }>
<span className = 'sr-only'>
{message.displayName === message.recipient
? t('chat.messageAccessibleTitleMe')
: t('chat.messageAccessibleTitle',
{ user: message.displayName })}
</span>
<Message text = { getMessageText(message) } />
<div className = { classes.sideBySideContainer }>
{!shouldDisplayChatMessageMenu && (
<div className = { classes.optionsButtonContainer }>
{isHovered && <MessageMenu
isLobbyMessage = { message.lobbyChat }
message = { message.message }
participantId = { message.participantId }
shouldDisplayChatMessageMenu = { shouldDisplayChatMessageMenu } />}
</div>
)}
<div
className = { cx(
'chatmessage',
classes.chatMessage,
type,
message.privateMessage && 'privatemessage',
message.lobbyChat && !knocking && 'lobbymessage'
) }>
<div className = { classes.replyWrapper }>
<div className = { cx('messagecontent', classes.messageContent) }>
{showDisplayName && _renderDisplayName()}
<div className = { cx('usermessage', classes.userMessage) }>
<span className = 'sr-only'>
{message.displayName === message.recipient
? t('chat.messageAccessibleTitleMe')
: t('chat.messageAccessibleTitle', {
user: message.displayName
})}
</span>
<Message text = { getMessageText(message) } />
{(message.privateMessage || (message.lobbyChat && !knocking))
&& _renderPrivateNotice()}
<div className = { classes.chatMessageFooter }>
<div className = { classes.chatMessageFooterLeft }>
{message.reactions && message.reactions.size > 0 && (
<>
{renderReactions()}
</>
)}
</div>
{_renderTimestamp()}
</div>
</div>
</div>
{(message.privateMessage || (message.lobbyChat && !knocking))
&& _renderPrivateNotice()}
</div>
{canReply
&& (
<div
className = { classes.replyButtonContainer }>
<PrivateMessageButton
</div>
{shouldDisplayChatMessageMenu && (
<div className = { classes.sideBySideContainer }>
{!message.privateMessage && <div>
<div className = { classes.optionsButtonContainer }>
{isHovered && <ReactButton
messageId = { message.messageId }
receiverId = { '' } />}
</div>
</div>}
<div>
<div className = { classes.optionsButtonContainer }>
{isHovered && <MessageMenu
isLobbyMessage = { message.lobbyChat }
participantID = { message.participantId } />
message = { message.message }
participantId = { message.participantId }
shouldDisplayChatMessageMenu = { shouldDisplayChatMessageMenu } />}
</div>
)}
</div>
</div>
</div>
)}
</div>
{showTimestamp && _renderTimestamp()}
</div>
);
};
@ -215,10 +409,12 @@ const ChatMessage = ({
*/
function _mapStateToProps(state: IReduxState, { message }: IProps) {
const { knocking } = state['features/lobby'];
const localParticipantId = state['features/base/participants'].local?.id;
return {
canReply: getCanReplyToMessage(state, message),
knocking
shouldDisplayChatMessageMenu: message.participantId !== localParticipantId,
knocking,
state
};
}

@ -73,6 +73,7 @@ const ChatMessageGroup = ({ className = '', messages }: IProps) => {
<ChatMessage
key = { i }
message = { message }
shouldDisplayChatMessageMenu = { false }
showDisplayName = { i === 0 }
showTimestamp = { i === messages.length - 1 }
type = { className } />

@ -0,0 +1,60 @@
import { Theme } from '@mui/material';
import React, { useCallback } from 'react';
import { makeStyles } from 'tss-react/mui';
interface IProps {
onSelect: (emoji: string) => void;
}
const useStyles = makeStyles()((theme: Theme) => {
return {
emojiGrid: {
display: 'flex',
flexDirection: 'row',
borderRadius: '4px',
backgroundColor: theme.palette.ui03
},
emojiButton: {
cursor: 'pointer',
padding: '5px',
fontSize: '1.5em'
}
};
});
const EmojiSelector: React.FC<IProps> = ({ onSelect }) => {
const { classes } = useStyles();
const emojiMap: Record<string, string> = {
thumbsUp: '👍',
redHeart: '❤',
faceWithTearsOfJoy: '😂',
faceWithOpenMouth: '😮',
fire: '🔥'
};
const emojiNames = Object.keys(emojiMap);
const handleSelect = useCallback(
(emoji: string) => (event: React.MouseEvent<HTMLSpanElement>) => {
event.preventDefault();
onSelect(emoji);
},
[ onSelect ]
);
return (
<div className = { classes.emojiGrid }>
{emojiNames.map(name => (
<span
className = { classes.emojiButton }
key = { name }
onClick = { handleSelect(emojiMap[name]) }>
{emojiMap[name]}
</span>
))}
</div>
);
};
export default EmojiSelector;

@ -0,0 +1,164 @@
import React, { useCallback, useRef, useState } from 'react';
import ReactDOM from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { IReduxState } from '../../../app/types';
import { IconDotsHorizontal } from '../../../base/icons/svg';
import { getParticipantById } from '../../../base/participants/functions';
import Popover from '../../../base/popover/components/Popover.web';
import Button from '../../../base/ui/components/web/Button';
import { BUTTON_TYPES } from '../../../base/ui/constants.any';
import { copyText } from '../../../base/util/copyText.web';
import { handleLobbyChatInitialized, openChat } from '../../actions.web';
export interface IProps {
className?: string;
isLobbyMessage: boolean;
message: string;
participantId: string;
shouldDisplayChatMessageMenu: boolean;
}
const useStyles = makeStyles()(theme => {
return {
messageMenuButton: {
padding: '2px'
},
menuItem: {
padding: '8px 16px',
cursor: 'pointer',
color: 'white',
'&:hover': {
backgroundColor: theme.palette.action03
}
},
menuPanel: {
backgroundColor: theme.palette.ui03,
borderRadius: theme.shape.borderRadius,
boxShadow: theme.shadows[3],
overflow: 'hidden'
},
copiedMessage: {
position: 'fixed',
backgroundColor: theme.palette.ui03,
color: 'white',
padding: '4px 8px',
borderRadius: '4px',
fontSize: '12px',
zIndex: 1000,
opacity: 0,
transition: 'opacity 0.3s ease-in-out',
pointerEvents: 'none'
},
showCopiedMessage: {
opacity: 1
}
};
});
const MessageMenu = ({ message, participantId, isLobbyMessage, shouldDisplayChatMessageMenu }: IProps) => {
const dispatch = useDispatch();
const { classes, cx } = useStyles();
const { t } = useTranslation();
const [ isPopoverOpen, setIsPopoverOpen ] = useState(false);
const [ showCopiedMessage, setShowCopiedMessage ] = useState(false);
const [ popupPosition, setPopupPosition ] = useState({ top: 0,
left: 0 });
const buttonRef = useRef<HTMLDivElement>(null);
const participant = useSelector((state: IReduxState) => getParticipantById(state, participantId));
const handleMenuClick = useCallback(() => {
setIsPopoverOpen(true);
}, []);
const handleClose = useCallback(() => {
setIsPopoverOpen(false);
}, []);
const handlePrivateClick = useCallback(() => {
if (isLobbyMessage) {
dispatch(handleLobbyChatInitialized(participantId));
} else {
dispatch(openChat(participant));
}
handleClose();
}, [ dispatch, isLobbyMessage, participant, participantId ]);
const handleCopyClick = useCallback(() => {
copyText(message)
.then(success => {
if (success) {
if (buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect();
setPopupPosition({
top: rect.top - 30,
left: rect.left
});
}
setShowCopiedMessage(true);
setTimeout(() => {
setShowCopiedMessage(false);
}, 2000);
} else {
console.error('Failed to copy text');
}
})
.catch(error => {
console.error('Error copying text:', error);
});
handleClose();
}, [ message ]);
const popoverContent = (
<div className = { classes.menuPanel }>
{shouldDisplayChatMessageMenu && (
<div
className = { classes.menuItem }
onClick = { handlePrivateClick }>
{t('Private Message')}
</div>
)}
<div
className = { classes.menuItem }
onClick = { handleCopyClick }>
{t('Copy')}
</div>
</div>
);
return (
<div>
<div ref = { buttonRef }>
<Popover
content = { popoverContent }
onPopoverClose = { handleClose }
position = 'top'
trigger = 'click'
visible = { isPopoverOpen }>
<Button
accessibilityLabel = { t('toolbar.accessibilityLabel.moreOptions') }
className = { classes.messageMenuButton }
icon = { IconDotsHorizontal }
onClick = { handleMenuClick }
type = { BUTTON_TYPES.TERTIARY } />
</Popover>
</div>
{showCopiedMessage && ReactDOM.createPortal(
<div
className = { cx(classes.copiedMessage, { [classes.showCopiedMessage]: showCopiedMessage }) }
style = {{ top: `${popupPosition.top}px`,
left: `${popupPosition.left}px` }}>
{t('Message Copied')}
</div>,
document.body
)}
</div>
);
};
export default MessageMenu;

@ -0,0 +1,87 @@
import { Theme } from '@mui/material';
import React, { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { IconFaceSmile } from '../../../base/icons/svg';
import Popover from '../../../base/popover/components/Popover.web';
import Button from '../../../base/ui/components/web/Button';
import { BUTTON_TYPES } from '../../../base/ui/constants.any';
import { sendReaction } from '../../actions.any';
import EmojiSelector from './EmojiSelector';
interface IProps {
messageId: string;
receiverId: string;
}
const useStyles = makeStyles()((theme: Theme) => {
return {
reactButton: {
padding: '2px'
},
reactionPanelContainer: {
position: 'relative',
display: 'inline-block'
},
popoverContent: {
backgroundColor: theme.palette.background.paper,
borderRadius: theme.shape.borderRadius,
boxShadow: theme.shadows[3],
overflow: 'hidden'
}
};
});
const ReactButton = ({ messageId, receiverId }: IProps) => {
const { classes } = useStyles();
const dispatch = useDispatch();
const { t } = useTranslation();
const onSendReaction = useCallback(emoji => {
dispatch(sendReaction(emoji, messageId, receiverId));
}, [ dispatch, messageId, receiverId ]);
const [ isPopoverOpen, setIsPopoverOpen ] = useState(false);
const handleReactClick = useCallback(() => {
setIsPopoverOpen(true);
}, []);
const handleClose = useCallback(() => {
setIsPopoverOpen(false);
}, []);
const handleEmojiSelect = useCallback((emoji: string) => {
onSendReaction(emoji);
handleClose();
}, [ onSendReaction, handleClose ]);
const popoverContent = (
<div className = { classes.popoverContent }>
<EmojiSelector onSelect = { handleEmojiSelect } />
</div>
);
return (
<Popover
content = { popoverContent }
onPopoverClose = { handleClose }
position = 'top'
trigger = 'click'
visible = { isPopoverOpen }>
<div className = { classes.reactionPanelContainer }>
<Button
accessibilityLabel = { t('toolbar.accessibilityLabel.react') }
className = { classes.reactButton }
icon = { IconFaceSmile }
onClick = { handleReactClick }
type = { BUTTON_TYPES.TERTIARY } />
</div>
</Popover>
);
};
export default ReactButton;

@ -34,9 +34,15 @@ import { ENDPOINT_REACTION_NAME } from '../reactions/constants';
import { getReactionMessageFromBuffer, isReactionsEnabled } from '../reactions/functions.any';
import { showToolbox } from '../toolbox/actions';
import { ADD_MESSAGE, CLOSE_CHAT, OPEN_CHAT, SEND_MESSAGE, SET_IS_POLL_TAB_FOCUSED } from './actionTypes';
import { addMessage, clearMessages, closeChat } from './actions.any';
import {
ADD_MESSAGE,
CLOSE_CHAT,
OPEN_CHAT,
SEND_MESSAGE,
SEND_REACTION,
SET_IS_POLL_TAB_FOCUSED
} from './actionTypes';
import { addMessage, addMessageReaction, clearMessages, closeChat } from './actions.any';
import { ChatPrivacyDialog } from './components';
import {
INCOMING_MSG_SOUND_ID,
@ -209,6 +215,18 @@ MiddlewareRegistry.register(store => next => action => {
break;
}
case SEND_REACTION: {
const state = store.getState();
const conference = getCurrentConference(state);
if (conference) {
const { reaction, messageId, receiverId } = action;
conference.sendReaction(reaction, messageId, receiverId);
}
break;
}
case ADD_REACTION_MESSAGE: {
if (localParticipant?.id) {
_handleReceivedMessage(store, {
@ -289,6 +307,17 @@ function _addChatMsgListener(conference: IJitsiConference, store: IStore) {
}
);
conference.on(
JitsiConferenceEvents.REACTION_RECEIVED,
(participantId: string, reactionList: string[], messageId: string) => {
_onReactionReceived(store, {
participantId,
reactionList,
messageId
});
}
);
conference.on(
JitsiConferenceEvents.PRIVATE_MESSAGE_RECEIVED,
(participantId: string, message: string, timestamp: number, messageId: string) => {
@ -341,6 +370,27 @@ function _onConferenceMessageReceived(store: IStore,
}, true, isGif);
}
/**
* Handles a received reaction.
*
* @param {Object} store - Redux store.
* @param {string} participantId - Id of the participant that sent the message.
* @param {string} reactionList - The list of received reactions.
* @param {string} messageId - The id of the message that the reaction is for.
* @returns {void}
*/
function _onReactionReceived(store: IStore, { participantId, reactionList, messageId }: {
messageId: string; participantId: string; reactionList: string[]; }) {
const reactionPayload = {
participantId,
reactionList,
messageId
};
store.dispatch(addMessageReaction(reactionPayload));
}
/**
* Handles a received gif message.
*

@ -3,6 +3,7 @@ import ReducerRegistry from '../base/redux/ReducerRegistry';
import {
ADD_MESSAGE,
ADD_MESSAGE_REACTION,
CLEAR_MESSAGES,
CLOSE_CHAT,
EDIT_MESSAGE,
@ -20,6 +21,7 @@ const DEFAULT_STATE = {
isPollsTabFocused: false,
lastReadMessage: undefined,
messages: [],
reactions: {},
nbUnreadMessages: 0,
privateMessageRecipient: undefined,
lobbyMessageRecipient: undefined,
@ -51,6 +53,7 @@ ReducerRegistry.register<IChatState>('features/chat', (state = DEFAULT_STATE, ac
messageId: action.messageId,
messageType: action.messageType,
message: action.message,
reactions: action.reactions,
privateMessage: action.privateMessage,
lobbyChat: action.lobbyChat,
recipient: action.recipient,
@ -77,6 +80,39 @@ ReducerRegistry.register<IChatState>('features/chat', (state = DEFAULT_STATE, ac
};
}
case ADD_MESSAGE_REACTION: {
const { participantId, reactionList, messageId } = action;
const messages = state.messages.map(message => {
if (messageId === message.messageId) {
const newReactions = new Map(message.reactions);
reactionList.forEach((reaction: string) => {
let participants = newReactions.get(reaction);
if (!participants) {
participants = new Set();
newReactions.set(reaction, participants);
}
participants.add(participantId);
});
return {
...message,
reactions: newReactions
};
}
return message;
});
return {
...state,
messages
};
}
case CLEAR_MESSAGES:
return {
...state,

@ -12,6 +12,7 @@ export interface IMessage {
messageType: string;
participantId: string;
privateMessage: boolean;
reactions: Map<string, Set<string>>;
recipient: string;
timestamp: number;
}
@ -59,6 +60,11 @@ export interface IChatMessageProps extends WithTranslation {
*/
message: IMessage;
/**
* Whether the chat message menu is visible or not.
*/
shouldDisplayChatMessageMenu?: boolean;
/**
* Whether or not the avatar image of the participant which sent the message
* should be displayed.

Loading…
Cancel
Save