mirror of
https://gitcode.com/GitHub_Trending/ji/jitsi-meet.git
synced 2025-12-30 11:22:31 +00:00
feat(chat) add message reactions
This commit is contained in:
@@ -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%'
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing(1),
|
||||
minWidth: '32px',
|
||||
minHeight: '32px'
|
||||
},
|
||||
|
||||
replyButton: {
|
||||
padding: '2px'
|
||||
},
|
||||
|
||||
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>
|
||||
{(message.privateMessage || (message.lobbyChat && !knocking))
|
||||
&& _renderPrivateNotice()}
|
||||
<div className = { classes.sideBySideContainer }>
|
||||
{!shouldDisplayChatMessageMenu && (
|
||||
<div className = { classes.optionsButtonContainer }>
|
||||
{isHovered && <MessageMenu
|
||||
isLobbyMessage = { message.lobbyChat }
|
||||
message = { message.message }
|
||||
participantId = { message.participantId }
|
||||
shouldDisplayChatMessageMenu = { shouldDisplayChatMessageMenu } />}
|
||||
</div>
|
||||
{canReply
|
||||
&& (
|
||||
<div
|
||||
className = { classes.replyButtonContainer }>
|
||||
<PrivateMessageButton
|
||||
isLobbyMessage = { message.lobbyChat }
|
||||
participantID = { message.participantId } />
|
||||
)}
|
||||
<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>
|
||||
</div>
|
||||
</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 }
|
||||
message = { message.message }
|
||||
participantId = { message.participantId }
|
||||
shouldDisplayChatMessageMenu = { shouldDisplayChatMessageMenu } />}
|
||||
</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 } />
|
||||
|
||||
60
react/features/chat/components/web/EmojiSelector.tsx
Normal file
60
react/features/chat/components/web/EmojiSelector.tsx
Normal file
@@ -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;
|
||||
164
react/features/chat/components/web/MessageMenu.tsx
Normal file
164
react/features/chat/components/web/MessageMenu.tsx
Normal file
@@ -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;
|
||||
87
react/features/chat/components/web/ReactButton.tsx
Normal file
87
react/features/chat/components/web/ReactButton.tsx
Normal file
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user