feat(chat): message reactions (#21385)

* feat(chat): message reactions

* fix: Revert settings.yml change introduced in #21355
This commit is contained in:
João Victor Nunes 2024-10-16 13:33:07 -03:00 committed by GitHub
parent 51c763dfc0
commit 93f82e2d90
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 195 additions and 35 deletions

View File

@ -35,4 +35,12 @@ export interface Message {
color: string; color: string;
}; };
} | null; } | null;
reactions: {
createdAt: string;
reactionEmoji: string;
user: {
name: string;
userId: string;
}
}[];
} }

View File

@ -4,6 +4,7 @@ import React, {
useEffect, useEffect,
useMemo, useMemo,
} from 'react'; } from 'react';
import { useMutation } from '@apollo/client';
import { UpdatedEventDetailsForChatMessageDomElements } from 'bigbluebutton-html-plugin-sdk/dist/cjs/dom-element-manipulation/chat/message/types'; import { UpdatedEventDetailsForChatMessageDomElements } from 'bigbluebutton-html-plugin-sdk/dist/cjs/dom-element-manipulation/chat/message/types';
import { Message } from '/imports/ui/Types/message'; import { Message } from '/imports/ui/Types/message';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
@ -34,6 +35,7 @@ import { Layout } from '/imports/ui/components/layout/layoutTypes';
import useChat from '/imports/ui/core/hooks/useChat'; import useChat from '/imports/ui/core/hooks/useChat';
import { GraphqlDataHookSubscriptionResponse } from '/imports/ui/Types/hook'; import { GraphqlDataHookSubscriptionResponse } from '/imports/ui/Types/hook';
import { Chat } from '/imports/ui/Types/chat'; import { Chat } from '/imports/ui/Types/chat';
import { CHAT_DELETE_REACTION_MUTATION, CHAT_SEND_REACTION_MUTATION } from './mutations';
interface ChatMessageProps { interface ChatMessageProps {
message: Message; message: Message;
@ -119,12 +121,34 @@ const ChatMesssage: React.FC<ChatMessageProps> = ({
} }
}, [message, messageRef]); }, [message, messageRef]);
const messageContentRef = React.createRef<HTMLDivElement>(); const messageContentRef = React.createRef<HTMLDivElement>();
const [reactions, setReactions] = React.useState<{ id: string, native: string }[]>([]);
const [editing, setEditing] = React.useState(false); const [editing, setEditing] = React.useState(false);
const [isToolbarMenuOpen, setIsToolbarMenuOpen] = React.useState(false); const [isToolbarMenuOpen, setIsToolbarMenuOpen] = React.useState(false);
const [isToolbarReactionPopoverOpen, setIsToolbarReactionPopoverOpen] = React.useState(false);
const chatFocusMessageRequest = useStorageKey(ChatEvents.CHAT_FOCUS_MESSAGE_REQUEST, STORAGES.IN_MEMORY); const chatFocusMessageRequest = useStorageKey(ChatEvents.CHAT_FOCUS_MESSAGE_REQUEST, STORAGES.IN_MEMORY);
const containerRef = React.useRef<HTMLDivElement>(null); const containerRef = React.useRef<HTMLDivElement>(null);
const animationInitialTimestamp = React.useRef(0); const animationInitialTimestamp = React.useRef(0);
const [chatSendReaction] = useMutation(CHAT_SEND_REACTION_MUTATION);
const [chatDeleteReaction] = useMutation(CHAT_DELETE_REACTION_MUTATION);
const sendReaction = useCallback((reactionEmoji: string) => {
chatSendReaction({
variables: {
chatId: message.chatId,
messageId: message.messageId,
reactionEmoji,
},
});
}, []);
const deleteReaction = useCallback((reactionEmoji: string) => {
chatDeleteReaction({
variables: {
chatId: message.chatId,
messageId: message.messageId,
reactionEmoji,
},
});
}, []);
const CHAT_TOOLBAR_CONFIG = window.meetingClientSettings.public.chat.toolbar; const CHAT_TOOLBAR_CONFIG = window.meetingClientSettings.public.chat.toolbar;
const isModerator = currentUser?.isModerator; const isModerator = currentUser?.isModerator;
@ -379,6 +403,7 @@ const ChatMesssage: React.FC<ChatMessageProps> = ({
isCustomPluginMessage={isCustomPluginMessage} isCustomPluginMessage={isCustomPluginMessage}
$highlight={hasToolbar} $highlight={hasToolbar}
$toolbarMenuIsOpen={isToolbarMenuOpen} $toolbarMenuIsOpen={isToolbarMenuOpen}
$reactionPopoverIsOpen={isToolbarReactionPopoverOpen}
> >
{hasToolbar && ( {hasToolbar && (
<ChatMessageToolbar <ChatMessageToolbar
@ -391,21 +416,18 @@ const ChatMesssage: React.FC<ChatMessageProps> = ({
messageSequence={message.messageSequence} messageSequence={message.messageSequence}
emphasizedMessage={message.chatEmphasizedText} emphasizedMessage={message.chatEmphasizedText}
onEmojiSelected={(emoji) => { onEmojiSelected={(emoji) => {
setReactions((prev) => { sendReaction(emoji.native);
return [ setIsToolbarReactionPopoverOpen(false);
...prev,
emoji,
];
});
}} }}
onEditRequest={() => { onEditRequest={() => {
setEditing(true); setEditing(true);
}} }}
onMenuOpenChange={setIsToolbarMenuOpen} onMenuOpenChange={setIsToolbarMenuOpen}
menuIsOpen={isToolbarMenuOpen} menuIsOpen={isToolbarMenuOpen}
onReactionPopoverOpenChange={setIsToolbarReactionPopoverOpen}
reactionPopoverIsOpen={isToolbarReactionPopoverOpen}
/> />
)} )}
<ChatMessageReactions reactions={reactions} />
{((!message?.user || !sameSender) && ( {((!message?.user || !sameSender) && (
message.messageType !== ChatMessageType.USER_AWAY_STATUS_MSG message.messageType !== ChatMessageType.USER_AWAY_STATUS_MSG
&& message.messageType !== ChatMessageType.API && message.messageType !== ChatMessageType.API
@ -432,6 +454,7 @@ const ChatMesssage: React.FC<ChatMessageProps> = ({
data-chat-message-id={message?.messageId} data-chat-message-id={message?.messageId}
$highlight={hasToolbar} $highlight={hasToolbar}
$toolbarMenuIsOpen={isToolbarMenuOpen} $toolbarMenuIsOpen={isToolbarMenuOpen}
$reactionPopoverIsOpen={isToolbarReactionPopoverOpen}
> >
{message.messageType !== ChatMessageType.CHAT_CLEAR {message.messageType !== ChatMessageType.CHAT_CLEAR
&& !isCustomPluginMessage && !isCustomPluginMessage
@ -474,6 +497,13 @@ const ChatMesssage: React.FC<ChatMessageProps> = ({
{intl.formatMessage(intlMessages.deleteMessage, { 0: message.deletedBy?.name })} {intl.formatMessage(intlMessages.deleteMessage, { 0: message.deletedBy?.name })}
</DeleteMessage> </DeleteMessage>
)} )}
{!deleteTime && (
<ChatMessageReactions
reactions={message.reactions}
deleteReaction={deleteReaction}
sendReaction={sendReaction}
/>
)}
</ChatContent> </ChatContent>
)} )}
{editing && ( {editing && (
@ -497,7 +527,8 @@ function areChatMessagesEqual(prevProps: ChatMessageProps, nextProps: ChatMessag
return prevMessage?.createdAt === nextMessage?.createdAt return prevMessage?.createdAt === nextMessage?.createdAt
&& prevMessage?.user?.currentlyInMeeting === nextMessage?.user?.currentlyInMeeting && prevMessage?.user?.currentlyInMeeting === nextMessage?.user?.currentlyInMeeting
&& prevMessage?.recipientHasSeen === nextMessage.recipientHasSeen && prevMessage?.recipientHasSeen === nextMessage.recipientHasSeen
&& prevMessage?.message === nextMessage.message; && prevMessage?.message === nextMessage.message
&& prevMessage?.reactions?.length === nextMessage?.reactions?.length;
} }
export default memo(ChatMesssage, areChatMessagesEqual); export default memo(ChatMesssage, areChatMessagesEqual);

View File

@ -1,29 +1,100 @@
import React from 'react'; import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import Styled from './styles'; import Styled from './styles';
import useCurrentUser from '/imports/ui/core/hooks/useCurrentUser';
import TooltipContainer from '/imports/ui/components/common/tooltip/container';
const intlMessages = defineMessages({
reactedBy: {
id: 'app.chat.toolbar.reactions.reactedByLabel',
},
you: {
id: 'app.chat.toolbar.reactions.youLabel',
},
and: {
id: 'app.chat.toolbar.reactions.andLabel',
},
findAReaction: {
id: 'app.chat.toolbar.reactions.findReactionButtonLabel',
},
});
interface ChatMessageReactionsProps { interface ChatMessageReactionsProps {
reactions: { reactions: {
id: string; createdAt: string;
native: string; reactionEmoji: string;
user: {
name: string;
userId: string;
};
}[]; }[];
sendReaction(reactionEmoji: string): void;
deleteReaction(reactionEmoji: string): void;
} }
const ChatMessageReactions: React.FC<ChatMessageReactionsProps> = (props) => { const ChatMessageReactions: React.FC<ChatMessageReactionsProps> = (props) => {
const { reactions } = props; const { reactions, sendReaction, deleteReaction } = props;
const { data: currentUser } = useCurrentUser((u) => ({ userId: u.userId }));
const intl = useIntl();
const reactionItems: Record<string, { count: number; userNames: string[]; reactedByMe: boolean }> = {};
reactions.forEach((reaction) => {
if (!reactionItems[reaction.reactionEmoji]) {
reactionItems[reaction.reactionEmoji] = {
count: 0,
userNames: [],
reactedByMe: false,
};
}
reactionItems[reaction.reactionEmoji].count += 1;
if (reaction.user.userId === currentUser?.userId) {
reactionItems[reaction.reactionEmoji].reactedByMe = true;
} else {
reactionItems[reaction.reactionEmoji].userNames.push(reaction.user.name);
}
});
return ( return (
<Styled.ReactionsWrapper> <Styled.ReactionsWrapper>
{reactions.map((emoji) => { {Object.keys(reactionItems).map((emoji) => {
const details = reactionItems[emoji];
let label = intl.formatMessage(intlMessages.reactedBy);
if (details.userNames.length) {
const users = details.userNames.join(', ');
label += ` ${users}`;
if (details.reactedByMe) {
label += ` ${intl.formatMessage(intlMessages.and)} ${intl.formatMessage(intlMessages.you)}`;
}
} else if (details.reactedByMe) {
label += ` ${intl.formatMessage(intlMessages.you)}`;
}
return ( return (
<Styled.EmojiWrapper highlighted={false}> <TooltipContainer title={label}>
<em-emoji <Styled.EmojiWrapper
emoji={emoji} highlighted={details.reactedByMe}
size={parseFloat( onClick={() => {
window.getComputedStyle(document.documentElement).fontSize, if (details.reactedByMe) {
)} deleteReaction(emoji);
native={emoji.native} } else {
/> sendReaction(emoji);
</Styled.EmojiWrapper> }
}}
>
{/* @ts-ignore */}
<em-emoji
size={parseFloat(
window.getComputedStyle(document.documentElement).fontSize,
)}
native={emoji}
/>
<span>{details.count}</span>
</Styled.EmojiWrapper>
</TooltipContainer>
); );
})} })}
</Styled.ReactionsWrapper> </Styled.ReactionsWrapper>

View File

@ -1,7 +1,7 @@
import styled from 'styled-components'; import styled from 'styled-components';
import { colorBlueLighter, colorBlueLightest, colorGray } from '/imports/ui/stylesheets/styled-components/palette'; import { colorBlueLighter, colorBlueLightest, colorGray } from '/imports/ui/stylesheets/styled-components/palette';
const EmojiWrapper = styled.div<{ highlighted: boolean }>` const EmojiWrapper = styled.button<{ highlighted: boolean }>`
background-color: ${colorBlueLightest}; background-color: ${colorBlueLightest};
border-radius: 10px; border-radius: 10px;
margin-left: 3px; margin-left: 3px;
@ -24,9 +24,6 @@ const EmojiWrapper = styled.div<{ highlighted: boolean }>`
const ReactionsWrapper = styled.div` const ReactionsWrapper = styled.div`
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
position: absolute;
bottom: 0;
left: 0;
`; `;
export default { export default {

View File

@ -50,12 +50,15 @@ interface ChatMessageToolbarProps {
onEditRequest(): void; onEditRequest(): void;
onMenuOpenChange(open: boolean): void; onMenuOpenChange(open: boolean): void;
menuIsOpen: boolean; menuIsOpen: boolean;
onReactionPopoverOpenChange(open: boolean): void;
reactionPopoverIsOpen: boolean;
} }
const ChatMessageToolbar: React.FC<ChatMessageToolbarProps> = (props) => { const ChatMessageToolbar: React.FC<ChatMessageToolbarProps> = (props) => {
const { const {
messageId, chatId, message, username, onEmojiSelected, onMenuOpenChange, messageId, chatId, message, username, onEmojiSelected, onMenuOpenChange,
messageSequence, emphasizedMessage, onEditRequest, own, amIModerator, menuIsOpen, messageSequence, emphasizedMessage, onEditRequest, own, amIModerator, menuIsOpen,
onReactionPopoverOpenChange, reactionPopoverIsOpen,
} = props; } = props;
const [reactionsAnchor, setReactionsAnchor] = React.useState<Element | null>( const [reactionsAnchor, setReactionsAnchor] = React.useState<Element | null>(
null, null,
@ -118,7 +121,12 @@ const ChatMessageToolbar: React.FC<ChatMessageToolbarProps> = (props) => {
].every((config) => !config)) return null; ].every((config) => !config)) return null;
return ( return (
<Container className="chat-message-toolbar" $sequence={messageSequence} $menuIsOpen={menuIsOpen}> <Container
className="chat-message-toolbar"
$sequence={messageSequence}
$menuIsOpen={menuIsOpen}
$reactionPopoverIsOpen={reactionPopoverIsOpen}
>
{CHAT_REPLIES_ENABLED && ( {CHAT_REPLIES_ENABLED && (
<> <>
<Button <Button
@ -154,8 +162,9 @@ const ChatMessageToolbar: React.FC<ChatMessageToolbarProps> = (props) => {
<EmojiButton <EmojiButton
onClick={(e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => { onClick={(e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.stopPropagation(); e.stopPropagation();
setReactionsAnchor(e.currentTarget); onReactionPopoverOpenChange(true);
}} }}
setRef={setReactionsAnchor}
size="sm" size="sm"
icon="happy" icon="happy"
color="light" color="light"
@ -204,10 +213,11 @@ const ChatMessageToolbar: React.FC<ChatMessageToolbarProps> = (props) => {
/> />
)} )}
<Popover <Popover
open={Boolean(reactionsAnchor)} open={reactionPopoverIsOpen}
anchorEl={reactionsAnchor} anchorEl={reactionsAnchor}
onClose={() => { onClose={() => {
setReactionsAnchor(null); setReactionsAnchor(null);
onReactionPopoverOpenChange(false);
}} }}
anchorOrigin={{ anchorOrigin={{
vertical: 'top', vertical: 'top',

View File

@ -8,7 +8,7 @@ import { borderRadius, smPaddingX } from '/imports/ui/stylesheets/styled-compone
import EmojiPickerComponent from '/imports/ui/components/emoji-picker/component'; import EmojiPickerComponent from '/imports/ui/components/emoji-picker/component';
import Button from '/imports/ui/components/common/button/component'; import Button from '/imports/ui/components/common/button/component';
const Container = styled.div<{ $sequence: number, $menuIsOpen: boolean }>` const Container = styled.div<{ $sequence: number, $menuIsOpen: boolean, $reactionPopoverIsOpen: boolean }>`
height: calc(1.5rem + 12px); height: calc(1.5rem + 12px);
line-height: calc(1.5rem + 8px); line-height: calc(1.5rem + 8px);
max-width: 184px; max-width: 184px;
@ -26,7 +26,7 @@ const Container = styled.div<{ $sequence: number, $menuIsOpen: boolean }>`
display: flex; display: flex;
} }
${({ $menuIsOpen }) => ($menuIsOpen && css` ${({ $menuIsOpen, $reactionPopoverIsOpen }) => (($menuIsOpen || $reactionPopoverIsOpen) && css`
display: flex; display: flex;
`)} `)}

View File

@ -12,12 +12,28 @@ const CHAT_DELETE_MESSAGE_MUTATION = gql`
} }
`; `;
const CHAT_SEND_REACTION_MUTATION = gql`
mutation($chatId: String!, $messageId: String!, $reactionEmoji: String!) {
chatSendMessageReaction(chatId: $chatId, messageId: $messageId, reactionEmoji: $reactionEmoji)
}
`;
const CHAT_DELETE_REACTION_MUTATION = gql`
mutation($chatId: String!, $messageId: String!, $reactionEmoji: String!) {
chatDeleteMessageReaction(chatId: $chatId, messageId: $messageId, reactionEmoji: $reactionEmoji)
}
`;
export default { export default {
CHAT_EDIT_MESSAGE_MUTATION, CHAT_EDIT_MESSAGE_MUTATION,
CHAT_DELETE_MESSAGE_MUTATION, CHAT_DELETE_MESSAGE_MUTATION,
CHAT_DELETE_REACTION_MUTATION,
CHAT_SEND_REACTION_MUTATION,
}; };
export { export {
CHAT_EDIT_MESSAGE_MUTATION, CHAT_EDIT_MESSAGE_MUTATION,
CHAT_DELETE_MESSAGE_MUTATION, CHAT_DELETE_MESSAGE_MUTATION,
CHAT_DELETE_REACTION_MUTATION,
CHAT_SEND_REACTION_MUTATION,
}; };

View File

@ -28,6 +28,7 @@ interface ChatWrapperProps {
isCustomPluginMessage: boolean; isCustomPluginMessage: boolean;
$highlight: boolean; $highlight: boolean;
$toolbarMenuIsOpen: boolean; $toolbarMenuIsOpen: boolean;
$reactionPopoverIsOpen: boolean;
} }
interface ChatContentProps { interface ChatContentProps {
@ -35,6 +36,7 @@ interface ChatContentProps {
isCustomPluginMessage: boolean; isCustomPluginMessage: boolean;
$highlight: boolean; $highlight: boolean;
$toolbarMenuIsOpen: boolean; $toolbarMenuIsOpen: boolean;
$reactionPopoverIsOpen: boolean;
} }
interface ChatAvatarProps { interface ChatAvatarProps {
@ -87,7 +89,9 @@ export const ChatWrapper = styled.div<ChatWrapperProps>`
} }
border-radius: 6px; border-radius: 6px;
`} `}
${({ sameSender, $toolbarMenuIsOpen }) => !sameSender && $toolbarMenuIsOpen && ` ${({ sameSender, $toolbarMenuIsOpen, $reactionPopoverIsOpen }) => !sameSender
&& ($toolbarMenuIsOpen || $reactionPopoverIsOpen)
&& `
background-color: ${colorOffWhite}; background-color: ${colorOffWhite};
border-radius: 6px; border-radius: 6px;
`} `}
@ -110,7 +114,9 @@ export const ChatContent = styled.div<ChatContentProps>`
border-radius: 6px; border-radius: 6px;
`} `}
${({ sameSender, $toolbarMenuIsOpen }) => sameSender && $toolbarMenuIsOpen && ` ${({ sameSender, $toolbarMenuIsOpen, $reactionPopoverIsOpen }) => sameSender
&& ($toolbarMenuIsOpen || $reactionPopoverIsOpen)
&& `
background-color: ${colorOffWhite}; background-color: ${colorOffWhite};
border-radius: 6px; border-radius: 6px;
`} `}

View File

@ -51,6 +51,7 @@ const areChatPagesEqual = (prevProps: ChatListPageProps, nextProps: ChatListPage
&& prevMessage?.user?.currentlyInMeeting === nextMessage?.user?.currentlyInMeeting && prevMessage?.user?.currentlyInMeeting === nextMessage?.user?.currentlyInMeeting
&& prevMessage?.recipientHasSeen === nextMessage?.recipientHasSeen && prevMessage?.recipientHasSeen === nextMessage?.recipientHasSeen
&& prevMessage?.message === nextMessage?.message && prevMessage?.message === nextMessage?.message
&& prevMessage?.reactions?.length === nextMessage?.reactions?.length
); );
}); });
}; };

View File

@ -25,6 +25,14 @@ export const CHAT_MESSAGE_PUBLIC_SUBSCRIPTION = gql`
color color
} }
} }
reactions {
createdAt
reactionEmoji
user {
name
userId
}
}
messageType messageType
chatEmphasizedText chatEmphasizedText
chatId chatId
@ -73,6 +81,14 @@ export const CHAT_MESSAGE_PRIVATE_SUBSCRIPTION = gql`
color color
} }
} }
reactions {
createdAt
reactionEmoji
user {
name
userId
}
}
chatId chatId
message message
messageType messageType

View File

@ -59,7 +59,7 @@ public:
askForFeedbackOnLogout: false askForFeedbackOnLogout: false
# the default logoutUrl matches window.location.origin i.e. bigbluebutton.org for demo.bigbluebutton.org # the default logoutUrl matches window.location.origin i.e. bigbluebutton.org for demo.bigbluebutton.org
# in some cases we want only custom logoutUrl to be used when provided on meeting create. Default value: true # in some cases we want only custom logoutUrl to be used when provided on meeting create. Default value: true
askForConfirmationOnLeave: false askForConfirmationOnLeave: true
wakeLock: wakeLock:
enabled: true enabled: true
allowDefaultLogoutUrl: true allowDefaultLogoutUrl: true
@ -768,7 +768,7 @@ public:
# e.g.: disableEmojis: ['grin','laughing'] # e.g.: disableEmojis: ['grin','laughing']
disableEmojis: [] disableEmojis: []
allowedElements: ['a', 'code', 'em', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'ol', 'ul', 'p', 'strong'] allowedElements: ['a', 'code', 'em', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'ol', 'ul', 'p', 'strong']
# available options for toolbar: reply, edit, delete # available options for toolbar: reply, edit, delete, reactions
# example: ['reply', 'delete'] # example: ['reply', 'delete']
toolbar: [] toolbar: []
userReaction: userReaction:

View File

@ -45,6 +45,10 @@
"app.chat.toolbar.reply": "Reply to message {0}", "app.chat.toolbar.reply": "Reply to message {0}",
"app.chat.toolbar.edit": "Edit", "app.chat.toolbar.edit": "Edit",
"app.chat.toolbar.delete": "Delete", "app.chat.toolbar.delete": "Delete",
"app.chat.toolbar.reactions.reactedByLabel": "Reacted by",
"app.chat.toolbar.reactions.youLabel": "you",
"app.chat.toolbar.reactions.andLabel": "and",
"app.chat.toolbar.reactions.findReactionButtonLabel": "Find a reaction",
"app.timer.toolTipTimerStopped": "The timer has stopped.", "app.timer.toolTipTimerStopped": "The timer has stopped.",
"app.timer.toolTipTimerRunning": "The timer is running.", "app.timer.toolTipTimerRunning": "The timer is running.",
"app.timer.toolTipStopwatchStopped": "The stopwatch has stopped.", "app.timer.toolTipStopwatchStopped": "The stopwatch has stopped.",