feat(chat): message reactions (#21385)
* feat(chat): message reactions * fix: Revert settings.yml change introduced in #21355
This commit is contained in:
parent
51c763dfc0
commit
93f82e2d90
@ -35,4 +35,12 @@ export interface Message {
|
||||
color: string;
|
||||
};
|
||||
} | null;
|
||||
reactions: {
|
||||
createdAt: string;
|
||||
reactionEmoji: string;
|
||||
user: {
|
||||
name: string;
|
||||
userId: string;
|
||||
}
|
||||
}[];
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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>
|
||||
|
@ -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 {
|
||||
|
@ -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',
|
||||
|
@ -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;
|
||||
`)}
|
||||
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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;
|
||||
`}
|
||||
|
@ -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
|
||||
);
|
||||
});
|
||||
};
|
||||
|
@ -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
|
||||
|
@ -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:
|
||||
|
@ -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.",
|
||||
|
Loading…
Reference in New Issue
Block a user