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;
};
} | null;
reactions: {
createdAt: string;
reactionEmoji: string;
user: {
name: string;
userId: string;
}
}[];
}

View File

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

View File

@ -1,29 +1,100 @@
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
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 {
reactions: {
id: string;
native: string;
createdAt: string;
reactionEmoji: string;
user: {
name: string;
userId: string;
};
}[];
sendReaction(reactionEmoji: string): void;
deleteReaction(reactionEmoji: string): void;
}
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 (
<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 (
<Styled.EmojiWrapper highlighted={false}>
<em-emoji
emoji={emoji}
size={parseFloat(
window.getComputedStyle(document.documentElement).fontSize,
)}
native={emoji.native}
/>
</Styled.EmojiWrapper>
<TooltipContainer title={label}>
<Styled.EmojiWrapper
highlighted={details.reactedByMe}
onClick={() => {
if (details.reactedByMe) {
deleteReaction(emoji);
} else {
sendReaction(emoji);
}
}}
>
{/* @ts-ignore */}
<em-emoji
size={parseFloat(
window.getComputedStyle(document.documentElement).fontSize,
)}
native={emoji}
/>
<span>{details.count}</span>
</Styled.EmojiWrapper>
</TooltipContainer>
);
})}
</Styled.ReactionsWrapper>

View File

@ -1,7 +1,7 @@
import styled from 'styled-components';
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};
border-radius: 10px;
margin-left: 3px;
@ -24,9 +24,6 @@ const EmojiWrapper = styled.div<{ highlighted: boolean }>`
const ReactionsWrapper = styled.div`
display: flex;
flex-wrap: wrap;
position: absolute;
bottom: 0;
left: 0;
`;
export default {

View File

@ -50,12 +50,15 @@ interface ChatMessageToolbarProps {
onEditRequest(): void;
onMenuOpenChange(open: boolean): void;
menuIsOpen: boolean;
onReactionPopoverOpenChange(open: boolean): void;
reactionPopoverIsOpen: boolean;
}
const ChatMessageToolbar: React.FC<ChatMessageToolbarProps> = (props) => {
const {
messageId, chatId, message, username, onEmojiSelected, onMenuOpenChange,
messageSequence, emphasizedMessage, onEditRequest, own, amIModerator, menuIsOpen,
onReactionPopoverOpenChange, reactionPopoverIsOpen,
} = props;
const [reactionsAnchor, setReactionsAnchor] = React.useState<Element | null>(
null,
@ -118,7 +121,12 @@ const ChatMessageToolbar: React.FC<ChatMessageToolbarProps> = (props) => {
].every((config) => !config)) return null;
return (
<Container className="chat-message-toolbar" $sequence={messageSequence} $menuIsOpen={menuIsOpen}>
<Container
className="chat-message-toolbar"
$sequence={messageSequence}
$menuIsOpen={menuIsOpen}
$reactionPopoverIsOpen={reactionPopoverIsOpen}
>
{CHAT_REPLIES_ENABLED && (
<>
<Button
@ -154,8 +162,9 @@ const ChatMessageToolbar: React.FC<ChatMessageToolbarProps> = (props) => {
<EmojiButton
onClick={(e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.stopPropagation();
setReactionsAnchor(e.currentTarget);
onReactionPopoverOpenChange(true);
}}
setRef={setReactionsAnchor}
size="sm"
icon="happy"
color="light"
@ -204,10 +213,11 @@ const ChatMessageToolbar: React.FC<ChatMessageToolbarProps> = (props) => {
/>
)}
<Popover
open={Boolean(reactionsAnchor)}
open={reactionPopoverIsOpen}
anchorEl={reactionsAnchor}
onClose={() => {
setReactionsAnchor(null);
onReactionPopoverOpenChange(false);
}}
anchorOrigin={{
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 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);
line-height: calc(1.5rem + 8px);
max-width: 184px;
@ -26,7 +26,7 @@ const Container = styled.div<{ $sequence: number, $menuIsOpen: boolean }>`
display: flex;
}
${({ $menuIsOpen }) => ($menuIsOpen && css`
${({ $menuIsOpen, $reactionPopoverIsOpen }) => (($menuIsOpen || $reactionPopoverIsOpen) && css`
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 {
CHAT_EDIT_MESSAGE_MUTATION,
CHAT_DELETE_MESSAGE_MUTATION,
CHAT_DELETE_REACTION_MUTATION,
CHAT_SEND_REACTION_MUTATION,
};
export {
CHAT_EDIT_MESSAGE_MUTATION,
CHAT_DELETE_MESSAGE_MUTATION,
CHAT_DELETE_REACTION_MUTATION,
CHAT_SEND_REACTION_MUTATION,
};

View File

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

View File

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

View File

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

View File

@ -59,7 +59,7 @@ public:
askForFeedbackOnLogout: false
# 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
askForConfirmationOnLeave: false
askForConfirmationOnLeave: true
wakeLock:
enabled: true
allowDefaultLogoutUrl: true
@ -768,7 +768,7 @@ public:
# e.g.: disableEmojis: ['grin','laughing']
disableEmojis: []
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']
toolbar: []
userReaction:

View File

@ -45,6 +45,10 @@
"app.chat.toolbar.reply": "Reply to message {0}",
"app.chat.toolbar.edit": "Edit",
"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.toolTipTimerRunning": "The timer is running.",
"app.timer.toolTipStopwatchStopped": "The stopwatch has stopped.",