feat(chat): message replies

This commit is contained in:
Joao 2024-10-02 19:28:02 -03:00
parent 4105271660
commit a4798695b4
25 changed files with 821 additions and 150 deletions

View File

@ -526,6 +526,7 @@ export interface Chat {
emojiPicker: EmojiPicker
disableEmojis: string[]
allowedElements: string[]
toolbar: string[]
}
export interface SystemMessagesKeys {

View File

@ -15,4 +15,13 @@ export interface Message {
messageMetadata: string;
recipientHasSeen: string;
user: User;
messageSequence: number;
replyToMessage: {
messageSequence: number;
message: string;
user: {
name: string;
color: string;
};
} | null;
}

View File

@ -134,6 +134,7 @@ const ChatMessageForm: React.FC<ChatMessageFormProps> = ({
const emojiPickerRef = useRef<HTMLDivElement>(null);
const emojiPickerButtonRef = useRef(null);
const [isTextAreaFocused, setIsTextAreaFocused] = React.useState(false);
const [repliedMessageId, setRepliedMessageId] = React.useState<string>();
const textAreaRef: RefObject<TextareaAutosize> = useRef<TextareaAutosize>(null);
const { isMobile } = deviceInfo;
const prevChatId = usePreviousValue(chatId);
@ -282,6 +283,21 @@ const ChatMessageForm: React.FC<ChatMessageFormProps> = ({
}));
}, [message]);
useEffect(() => {
const handler = (e: Event) => {
if (e instanceof CustomEvent) {
setRepliedMessageId(e.detail.messageId);
textAreaRef.current?.textarea.focus();
}
};
window.addEventListener(ChatEvents.CHAT_REPLY_INTENTION, handler);
return () => {
window.removeEventListener(ChatEvents.CHAT_REPLY_INTENTION, handler);
};
}, []);
const renderForm = () => {
const formRef = useRef<HTMLFormElement | null>(null);
@ -303,8 +319,20 @@ const ChatMessageForm: React.FC<ChatMessageFormProps> = ({
variables: {
chatMessageInMarkdownFormat: msg,
chatId: chatId === PUBLIC_CHAT_ID ? PUBLIC_GROUP_CHAT_ID : chatId,
replyToMessageId: repliedMessageId,
},
});
window.dispatchEvent(
new CustomEvent(ChatEvents.CHAT_REPLY_INTENTION, {
detail: {
username: undefined,
message: undefined,
messageId: undefined,
chatId: undefined,
},
}),
);
}
const currentClosedChats = Storage.getItem(CLOSED_CHAT_LIST_KEY);

View File

@ -2,10 +2,11 @@ import { gql } from '@apollo/client';
// Define the GraphQL mutation
export const CHAT_SEND_MESSAGE = gql`
mutation ChatSendMessage($chatId: String!, $chatMessageInMarkdownFormat: String!) {
mutation ChatSendMessage($chatId: String!, $chatMessageInMarkdownFormat: String!, $replyToMessageId: String) {
chatSendMessage(
chatId: $chatId,
chatMessageInMarkdownFormat: $chatMessageInMarkdownFormat
chatMessageInMarkdownFormat: $chatMessageInMarkdownFormat,
replyToMessageId: $replyToMessageId
)
}
`;

View File

@ -21,9 +21,7 @@ import LAST_SEEN_MUTATION from './queries';
import {
ButtonLoadMore,
MessageList,
MessageListWrapper,
UnreadButton,
ChatMessages,
} from './styles';
import useReactiveRef from '/imports/ui/hooks/useReactiveRef';
import useStickyScroll from '/imports/ui/hooks/useStickyScroll';
@ -113,10 +111,13 @@ const ChatMessageList: React.FC<ChatListProps> = ({
isRTL,
}) => {
const intl = useIntl();
const contentRef = React.useRef<HTMLDivElement>(null);
// I used a ref here because I don't want to re-render the component when the last sender changes
const lastSenderPerPage = React.useRef<Map<number, string>>(new Map());
const sentinelRef = React.useRef<HTMLDivElement | null>(null);
const {
ref: messageListContainerRef,
current: currentMessageListContainer,
} = useReactiveRef<HTMLDivElement>(null);
const {
ref: messageListRef,
current: currentMessageList,
@ -127,12 +128,12 @@ const ChatMessageList: React.FC<ChatListProps> = ({
const {
childRefProxy: sentinelRefProxy,
intersecting: isSentinelVisible,
parentRefProxy: messageListRefProxy,
} = useIntersectionObserver(messageListRef, sentinelRef);
parentRefProxy: messageListContainerRefProxy,
} = useIntersectionObserver(messageListContainerRef, sentinelRef);
const {
startObserving,
stopObserving,
} = useStickyScroll(currentMessageList);
} = useStickyScroll(currentMessageListContainer, currentMessageList);
useEffect(() => {
if (isSentinelVisible) startObserving(); else stopObserving();
@ -159,6 +160,21 @@ const ChatMessageList: React.FC<ChatListProps> = ({
}
}, [lastMessageCreatedAt]);
useEffect(() => {
const handler = (e: Event) => {
if (e instanceof CustomEvent) {
toggleFollowingTail(false);
setUserLoadedBackUntilPage(Math.ceil(e.detail.sequence / PAGE_SIZE) - 1);
}
};
window.addEventListener(ChatEvents.CHAT_FOCUS_MESSAGE_REQUEST, handler);
return () => {
window.removeEventListener(ChatEvents.CHAT_FOCUS_MESSAGE_REQUEST, handler);
};
}, []);
const markMessageAsSeen = useCallback((message: Message) => {
if (new Date(message.createdAt).getTime() > new Date((lastMessageCreatedAt || 0)).getTime()) {
dispatchLastSeen();
@ -179,14 +195,14 @@ const ChatMessageList: React.FC<ChatListProps> = ({
const toggleFollowingTail = (toggle: boolean) => {
setFollowingTail(toggle);
if (isElement(contentRef.current)) {
if (isElement(messageListRef.current)) {
if (toggle) {
scrollObserver.observe(contentRef.current);
scrollObserver.observe(messageListRef.current);
} else {
if (userLoadedBackUntilPage === null) {
setUserLoadedBackUntilPage(Math.max(totalPages - 2, 0));
}
scrollObserver.unobserve(contentRef.current);
scrollObserver.unobserve(messageListRef.current);
}
}
};
@ -246,67 +262,56 @@ const ChatMessageList: React.FC<ChatListProps> = ({
<>
{
[
<MessageListWrapper key="message-list-wrapper" id="chat-list">
<MessageList
ref={messageListRefProxy}
onMouseUp={() => {
setScrollToTailEventHandler();
<MessageList
id="chat-list"
key="message-list-wrapper"
onMouseUp={() => {
setScrollToTailEventHandler();
}}
onTouchEnd={() => {
setScrollToTailEventHandler();
}}
data-test="chatMessages"
isRTL={isRTL}
ref={messageListContainerRefProxy}
>
<div ref={messageListRef}>
{userLoadedBackUntilPage ? (
<ButtonLoadMore
onClick={() => {
if (followingTail) {
toggleFollowingTail(false);
}
setUserLoadedBackUntilPage(userLoadedBackUntilPage - 1);
}}
>
{intl.formatMessage(intlMessages.loadMoreButtonLabel)}
</ButtonLoadMore>
) : null}
<ChatPopupContainer />
{Array.from({ length: pagesToLoad }, (_v, k) => k + (firstPageToLoad)).map((page) => {
return (
<ChatListPage
key={`page-${page}`}
page={page}
pageSize={PAGE_SIZE}
setLastSender={() => setLastSender(lastSenderPerPage.current)}
lastSenderPreviousPage={page ? lastSenderPerPage.current.get(page - 1) : undefined}
chatId={chatId}
markMessageAsSeen={markMessageAsSeen}
scrollRef={messageListContainerRefProxy}
/>
);
})}
</div>
<div
ref={sentinelRefProxy}
style={{
height: 1,
background: 'none',
}}
onTouchEnd={() => {
setScrollToTailEventHandler();
}}
>
<span>
{
(userLoadedBackUntilPage)
? (
<ButtonLoadMore
onClick={() => {
if (followingTail) {
toggleFollowingTail(false);
}
setUserLoadedBackUntilPage(userLoadedBackUntilPage - 1);
}}
>
{intl.formatMessage(intlMessages.loadMoreButtonLabel)}
</ButtonLoadMore>
) : null
}
</span>
<ChatMessages
id="contentRef"
ref={contentRef}
data-test="chatMessages"
isRTL={isRTL}
>
<ChatPopupContainer />
{
// @ts-ignore
Array.from({ length: pagesToLoad }, (v, k) => k + (firstPageToLoad)).map((page) => {
return (
<ChatListPage
key={`page-${page}`}
page={page}
pageSize={PAGE_SIZE}
setLastSender={() => setLastSender(lastSenderPerPage.current)}
lastSenderPreviousPage={page ? lastSenderPerPage.current.get(page - 1) : undefined}
chatId={chatId}
markMessageAsSeen={markMessageAsSeen}
scrollRef={messageListRef}
/>
);
})
}
</ChatMessages>
<div
ref={sentinelRefProxy}
style={{
height: 1,
background: 'none',
}}
/>
</MessageList>
</MessageListWrapper>,
/>
</MessageList>,
renderUnreadNotification,
]
}

View File

@ -11,9 +11,14 @@ import {
ChatContent,
ChatAvatar,
MessageItemWrapper,
Container,
} from './styles';
import { ChatMessageType } from '/imports/ui/core/enums/chat';
import { ChatEvents, ChatMessageType } from '/imports/ui/core/enums/chat';
import MessageReadConfirmation from './message-read-confirmation/component';
import ChatMessageToolbar from './message-toolbar/component';
import ChatMessageReactions from './message-reactions/component';
import ChatMessageReplied from './message-replied/component';
import { STORAGES, useStorageKey } from '/imports/ui/services/storage/hooks';
interface ChatMessageProps {
message: Message;
@ -58,6 +63,8 @@ function isInViewport(el: HTMLDivElement) {
const messageRef = React.createRef<HTMLDivElement>();
const ANIMATION_DURATION = 2000;
const ChatMesssage: React.FC<ChatMessageProps> = ({
previousMessage,
lastSenderPreviousPage,
@ -74,6 +81,52 @@ const ChatMesssage: React.FC<ChatMessageProps> = ({
}
}, [message, messageRef]);
const messageContentRef = React.createRef<HTMLDivElement>();
const [reactions, setReactions] = React.useState<{ id: string, native: string }[]>([]);
const chatFocusMessageRequest = useStorageKey(ChatEvents.CHAT_FOCUS_MESSAGE_REQUEST, STORAGES.IN_MEMORY);
const containerRef = React.useRef<HTMLDivElement>(null);
const animationInitialTimestamp = React.useRef(0);
const startScrollAnimation = (timestamp: number) => {
if (scrollRef.current && containerRef.current) {
// eslint-disable-next-line no-param-reassign
scrollRef.current.scrollTop = containerRef.current.offsetTop;
}
animationInitialTimestamp.current = timestamp;
requestAnimationFrame(animate);
};
useEffect(() => {
const handler = (e: Event) => {
if (e instanceof CustomEvent) {
if (e.detail.sequence === message.messageSequence) {
requestAnimationFrame(startScrollAnimation);
}
}
};
window.addEventListener(ChatEvents.CHAT_FOCUS_MESSAGE_REQUEST, handler);
return () => {
window.removeEventListener(ChatEvents.CHAT_FOCUS_MESSAGE_REQUEST, handler);
};
}, []);
useEffect(() => {
if (chatFocusMessageRequest === message.messageSequence) {
requestAnimationFrame(startScrollAnimation);
}
}, []);
const animate = useCallback((timestamp: number) => {
if (!containerRef.current) return;
const value = (timestamp - animationInitialTimestamp.current) / ANIMATION_DURATION;
if (value < 1) {
containerRef.current.style.backgroundColor = `rgba(243, 246, 249, ${1 - value})`;
requestAnimationFrame(animate);
} else {
containerRef.current.style.backgroundColor = 'unset';
}
}, []);
useEffect(() => {
setMessagesRequestedFromPlugin((messages) => {
@ -250,38 +303,64 @@ const ChatMesssage: React.FC<ChatMessageProps> = ({
}
}, []);
return (
<ChatWrapper
isSystemSender={isSystemSender}
sameSender={sameSender}
ref={messageRef}
isPresentationUpload={messageContent.isPresentationUpload}
isCustomPluginMessage={isCustomPluginMessage}
>
{((!message?.user || !sameSender) && (
message.messageType !== ChatMessageType.USER_AWAY_STATUS_MSG
<Container ref={containerRef}>
{message.replyToMessage && (
<ChatMessageReplied
message={message.replyToMessage.message}
username={message.replyToMessage.user.name}
sequence={message.replyToMessage.messageSequence}
userColor={message.replyToMessage.user.color}
/>
)}
<ChatWrapper
id="chat-message-wrapper"
isSystemSender={isSystemSender}
sameSender={sameSender}
ref={messageRef}
isPresentationUpload={messageContent.isPresentationUpload}
isCustomPluginMessage={isCustomPluginMessage}
>
<ChatMessageToolbar
messageId={message.messageId}
chatId={message.chatId}
username={message.user.name}
message={message.message}
messageSequence={message.messageSequence}
onEmojiSelected={(emoji) => {
setReactions((prev) => {
return [
...prev,
emoji,
];
});
}}
/>
<ChatMessageReactions reactions={reactions} />
{((!message?.user || !sameSender) && (
message.messageType !== ChatMessageType.USER_AWAY_STATUS_MSG
&& message.messageType !== ChatMessageType.API
&& message.messageType !== ChatMessageType.CHAT_CLEAR
&& !isCustomPluginMessage)
) && (
<ChatAvatar
avatar={message.user?.avatar}
color={messageContent.color}
moderator={messageContent.isModerator}
>
{!messageContent.avatarIcon ? (
!message.user || (message.user?.avatar.length === 0 ? messageContent.name.toLowerCase().slice(0, 2) : '')
) : (
<i className={messageContent.avatarIcon} />
) && (
<ChatAvatar
avatar={message.user?.avatar}
color={messageContent.color}
moderator={messageContent.isModerator}
>
{!messageContent.avatarIcon ? (
!message.user || (message.user?.avatar.length === 0 ? messageContent.name.toLowerCase().slice(0, 2) : '')
) : (
<i className={messageContent.avatarIcon} />
)}
</ChatAvatar>
)}
</ChatAvatar>
)}
<ChatContent
ref={messageContentRef}
sameSender={message?.user ? sameSender : false}
isCustomPluginMessage={isCustomPluginMessage}
data-chat-message-id={message?.messageId}
>
{message.messageType !== ChatMessageType.CHAT_CLEAR
<ChatContent
ref={messageContentRef}
sameSender={message?.user ? sameSender : false}
isCustomPluginMessage={isCustomPluginMessage}
data-chat-message-id={message?.messageId}
>
{message.messageType !== ChatMessageType.CHAT_CLEAR
&& !isCustomPluginMessage
&& (
<ChatMessageHeader
@ -291,16 +370,17 @@ const ChatMesssage: React.FC<ChatMessageProps> = ({
dateTime={dateTime}
/>
)}
<MessageItemWrapper>
{messageContent.component}
{messageReadFeedbackEnabled && (
<MessageItemWrapper>
{messageContent.component}
{messageReadFeedbackEnabled && (
<MessageReadConfirmation
message={message}
/>
)}
</MessageItemWrapper>
</ChatContent>
</ChatWrapper>
)}
</MessageItemWrapper>
</ChatContent>
</ChatWrapper>
</Container>
);
};

View File

@ -0,0 +1,33 @@
import React from 'react';
import Styled from './styles';
interface ChatMessageReactionsProps {
reactions: {
id: string;
native: string;
}[];
}
const ChatMessageReactions: React.FC<ChatMessageReactionsProps> = (props) => {
const { reactions } = props;
return (
<Styled.ReactionsWrapper>
{reactions.map((emoji) => {
return (
<Styled.EmojiWrapper highlighted={false}>
<em-emoji
emoji={emoji}
size={parseFloat(
window.getComputedStyle(document.documentElement).fontSize,
)}
native={emoji.native}
/>
</Styled.EmojiWrapper>
);
})}
</Styled.ReactionsWrapper>
);
};
export default ChatMessageReactions;

View File

@ -0,0 +1,35 @@
import styled from 'styled-components';
import { colorBlueLighter, colorBlueLightest, colorGray } from '/imports/ui/stylesheets/styled-components/palette';
const EmojiWrapper = styled.div<{ highlighted: boolean }>`
background-color: ${colorBlueLightest};
border-radius: 10px;
margin-left: 3px;
margin-top: 3px;
padding: 3px;
display: flex;
flex-wrap: nowrap;
border: 1px solid transparent;
cursor: pointer;
${({ highlighted }) => highlighted && `
background-color: ${colorBlueLighter};
`}
&:hover {
border: 1px solid ${colorGray};
}
`;
const ReactionsWrapper = styled.div`
display: flex;
flex-wrap: wrap;
position: absolute;
bottom: 0;
left: 0;
`;
export default {
EmojiWrapper,
ReactionsWrapper,
};

View File

@ -0,0 +1,38 @@
import React from 'react';
import Styled from './styles';
import Storage from '/imports/ui/services/storage/in-memory';
import { ChatEvents } from '/imports/ui/core/enums/chat';
interface MessageRepliedProps {
username: string;
message: string;
sequence: number;
userColor: string;
}
const ChatMessageReplied: React.FC<MessageRepliedProps> = (props) => {
const {
message, username, sequence, userColor,
} = props;
return (
<Styled.Container
$userColor={userColor}
onClick={() => {
window.dispatchEvent(
new CustomEvent(ChatEvents.CHAT_FOCUS_MESSAGE_REQUEST, {
detail: {
sequence,
},
}),
);
Storage.removeItem(ChatEvents.CHAT_FOCUS_MESSAGE_REQUEST);
Storage.setItem(ChatEvents.CHAT_FOCUS_MESSAGE_REQUEST, sequence);
}}
>
<Styled.Username $userColor={userColor}>{username}</Styled.Username>
<Styled.Message>{message}</Styled.Message>
</Styled.Container>
);
};
export default ChatMessageReplied;

View File

@ -0,0 +1,35 @@
import styled from 'styled-components';
import { colorOffWhite } from '/imports/ui/stylesheets/styled-components/palette';
const Container = styled.div<{ $userColor: string }>`
border-radius: 4px;
border-left: 4px solid ${({ $userColor }) => $userColor};
background-color: ${colorOffWhite};
padding: 6px;
position: relative;
margin: 0.25rem 0 0.25rem 2.6rem;
overflow: hidden;
cursor: pointer;
`;
const Typography = styled.div`
line-height: 1rem;
font-size: 1rem;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
`;
const Username = styled(Typography)<{ $userColor: string }>`
font-weight: bold;
color: ${({ $userColor }) => $userColor};
margin-bottom: 6px;
`;
const Message = styled(Typography)``;
export default {
Container,
Username,
Message,
};

View File

@ -0,0 +1,176 @@
import React from 'react';
import Popover from '@mui/material/Popover';
import { layoutSelect } from '/imports/ui/components/layout/context';
import Button from '/imports/ui/components/common/button/component';
import BBBMenu from '/imports/ui/components/common/menu/component';
import { Layout } from '/imports/ui/components/layout/layoutTypes';
import { ChatEvents } from '/imports/ui/core/enums/chat';
import { defineMessages, useIntl } from 'react-intl';
import {
Container,
EmojiPicker,
EmojiPickerWrapper,
EmojiButton,
} from './styles';
const intlMessages = defineMessages({
reply: {
id: 'app.chat.toolbar.reply',
description: 'reply label',
},
});
interface ChatMessageToolbarProps {
messageId: string;
chatId: string;
username: string;
message: string;
messageSequence: number;
onEmojiSelected(emoji: { id: string; native: string }): void;
}
const ChatMessageToolbar: React.FC<ChatMessageToolbarProps> = (props) => {
const {
messageId, chatId, message, username, onEmojiSelected, messageSequence,
} = props;
const [reactionsAnchor, setReactionsAnchor] = React.useState<Element | null>(
null,
);
const intl = useIntl();
const isRTL = layoutSelect((i: Layout) => i.isRTL);
const CHAT_TOOLBAR_CONFIG = window.meetingClientSettings.public.chat.toolbar;
const CHAT_REPLIES_ENABLED = CHAT_TOOLBAR_CONFIG.includes('replies');
const CHAT_REACTIONS_ENABLED = CHAT_TOOLBAR_CONFIG.includes('reactions');
const CHAT_EDIT_ENABLED = CHAT_TOOLBAR_CONFIG.includes('edit');
const CHAT_DELETE_ENABLED = CHAT_TOOLBAR_CONFIG.includes('delete');
const actions = [];
if (CHAT_EDIT_ENABLED) {
actions.push({
key: 'edit',
icon: 'pen_tool',
label: 'Edit',
onClick: () => null,
});
}
if (CHAT_DELETE_ENABLED) {
actions.push({
key: 'delete',
icon: 'delete',
label: 'Delete',
onClick: () => null,
});
}
if (!CHAT_TOOLBAR_CONFIG.length) return null;
return (
<Container className="chat-message-toolbar" $sequence={messageSequence}>
{CHAT_REPLIES_ENABLED && (
<>
<Button
circle
ghost
aria-describedby={`chat-reply-btn-label-${messageSequence}`}
icon="undo"
color="light"
size="sm"
type="button"
onClick={(e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.stopPropagation();
window.dispatchEvent(
new CustomEvent(ChatEvents.CHAT_REPLY_INTENTION, {
detail: {
username,
message,
messageId,
chatId,
},
}),
);
}}
/>
<span id={`chat-reply-btn-label-${messageSequence}`} className="sr-only">
{intl.formatMessage(intlMessages.reply, { 0: messageSequence })}
</span>
</>
)}
{CHAT_REACTIONS_ENABLED && (
<EmojiButton
onClick={(e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.stopPropagation();
setReactionsAnchor(e.currentTarget);
}}
size="sm"
icon="happy"
color="light"
ghost
type="button"
circle
data-test="emojiPickerButton"
/>
)}
{actions.length > 0 && (
<BBBMenu
trigger={(
<Button
onClick={() => null}
size="sm"
icon="more"
color="light"
ghost
type="button"
circle
/>
)}
actions={actions}
opts={{
id: 'app-settings-dropdown-menu',
keepMounted: true,
transitionDuration: 0,
elevation: 3,
getcontentanchorel: null,
fullwidth: 'true',
anchorOrigin: {
vertical: 'bottom',
horizontal: isRTL ? 'left' : 'right',
},
transformorigin: {
vertical: 'top',
horizontal: isRTL ? 'left' : 'right',
},
}}
/>
)}
<Popover
open={Boolean(reactionsAnchor)}
anchorEl={reactionsAnchor}
onClose={() => {
setReactionsAnchor(null);
}}
anchorOrigin={{
vertical: 'top',
horizontal: isRTL ? 'left' : 'right',
}}
transformOrigin={{
vertical: 'top',
horizontal: isRTL ? 'right' : 'left',
}}
>
<EmojiPickerWrapper>
<EmojiPicker
onEmojiSelect={(emojiObject: { id: string; native: string }) => {
onEmojiSelected(emojiObject);
}}
showPreview={false}
showSkinTones={false}
/>
</EmojiPickerWrapper>
</Popover>
</Container>
);
};
export default ChatMessageToolbar;

View File

@ -0,0 +1,94 @@
import styled, { css } from 'styled-components';
import {
colorGrayLighter,
colorOffWhite,
colorWhite,
} from '/imports/ui/stylesheets/styled-components/palette';
import { borderRadius, smPaddingX } from '/imports/ui/stylesheets/styled-components/general';
import EmojiPickerComponent from '/imports/ui/components/emoji-picker/component';
import Button from '/imports/ui/components/common/button/component';
const Container = styled.div<{ $sequence: number }>`
height: calc(1.5rem + 12px);
line-height: calc(1.5rem + 8px);
max-width: 184px;
overflow: hidden;
display: none;
position: absolute;
right: 0;
border: 1px solid ${colorOffWhite};
border-radius: 8px;
padding: 1px;
background-color: ${colorWhite};
#chat-message-wrapper:hover & {
display: flex;
}
${({ $sequence }) => (($sequence === 1)
? css`
bottom: 0;
transform: translateY(50%);
`
: css`
top: 0;
transform: translateY(-50%);
`)
}
`;
const EmojiPickerWrapper = styled.div`
bottom: calc(100% + 0.5rem);
left: 0;
right: 0;
border: 1px solid ${colorGrayLighter};
border-radius: ${borderRadius};
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
z-index: 1000;
.emoji-mart {
max-width: 100% !important;
}
.emoji-mart-anchor {
cursor: pointer;
}
.emoji-mart-emoji {
cursor: pointer !important;
}
.emoji-mart-category-list {
span {
cursor: pointer !important;
display: inline-block !important;
}
}
`;
// @ts-ignore
const EmojiButton = styled(Button)`
margin:0 0 0 ${smPaddingX};
align-self: center;
font-size: 0.5rem;
[dir="rtl"] & {
margin: 0 ${smPaddingX} 0 0;
-webkit-transform: scale(-1, 1);
-moz-transform: scale(-1, 1);
-ms-transform: scale(-1, 1);
-o-transform: scale(-1, 1);
transform: scale(-1, 1);
}
`;
const EmojiPicker = styled(EmojiPickerComponent)`
position: relative;
`;
export {
Container,
EmojiPicker,
EmojiPickerWrapper,
EmojiButton,
};

View File

@ -14,6 +14,7 @@ import {
colorWhite,
userListBg,
colorSuccess,
colorOffWhite,
} from '/imports/ui/stylesheets/styled-components/palette';
import Header from '/imports/ui/components/common/control-header/component';
@ -74,6 +75,12 @@ export const ChatWrapper = styled.div<ChatWrapperProps>`
margin: 0;
padding: 0;
`}
${({ sameSender }) => !sameSender && `
&:hover {
background-color: ${colorOffWhite};
}
border-radius: 6px;
`}
`;
export const ChatContent = styled.div<ChatContentProps>`
@ -85,6 +92,13 @@ export const ChatContent = styled.div<ChatContentProps>`
&& !isCustomPluginMessage && `
margin-left: 2.6rem;
`}
${({ sameSender }) => sameSender && `
&:hover {
background-color: ${colorOffWhite};
}
border-radius: 6px;
`}
`;
export const ChatHeader = styled(Header)`
@ -112,7 +126,6 @@ export const ChatAvatar = styled.div<ChatAvatarProps>`
${({ color }) => css`
background-color: ${color};
`}
}
&:after,
&:before {
@ -171,13 +184,18 @@ export const ChatAvatar = styled.div<ChatAvatarProps>`
justify-content: center;
align-items:center;
// ================ content ================
& .react-loading-skeleton {
height: 2.25rem;
width: 2.25rem;
}
`;
export const Container = styled.div`
display: flex;
flex-direction: column;
`;
export const MessageItemWrapper = styled.div`
display: flex;
flex-direction: row;

View File

@ -63,8 +63,7 @@ const ChatListPage: React.FC<ChatListPageProps> = ({
}, [domElementManipulationIdentifiers, messagesRequestedFromPlugin]);
return (
// eslint-disable-next-line react/jsx-filename-extension
<div key={`messagePage-${page}`} id={`${page}`}>
<React.Fragment key={`messagePage-${page}`}>
{messages.map((message, index, messagesArray) => {
const previousMessage = messagesArray[index - 1];
return (
@ -82,7 +81,7 @@ const ChatListPage: React.FC<ChatListPageProps> = ({
/>
);
})}
</div>
</React.Fragment>
);
};

View File

@ -12,6 +12,15 @@ export const CHAT_MESSAGE_PUBLIC_SUBSCRIPTION = gql`
isModerator
color
}
messageSequence
replyToMessage {
messageSequence
message
user {
name
color
}
}
messageType
chatEmphasizedText
chatId

View File

@ -8,22 +8,30 @@ import { ScrollboxVertical } from '/imports/ui/stylesheets/styled-components/scr
import { colorGrayDark } from '/imports/ui/stylesheets/styled-components/palette';
import { ButtonElipsis } from '/imports/ui/stylesheets/styled-components/placeholders';
interface ChatMessagesProps {
interface MessageListProps {
isRTL: boolean;
}
export const MessageListWrapper = styled.div`
height: 100%;
display: flex;
export const MessageList = styled(ScrollboxVertical)<MessageListProps>`
flex-flow: column;
flex-shrink: 1;
position: relative;
right: 0 ${mdPaddingX} 0 0;
padding-top: 0;
outline-style: none;
overflow-x: hidden;
overflow-y: auto;
user-select: text;
height: 100%;
z-index: 2;
`;
overflow-y: auto;
position: relative;
display: flex;
padding-bottom: ${smPaddingX};
[dir='rtl'] & {
margin: 0 0 0 auto;
padding: 0 0 0 ${mdPaddingX};
}
export const ChatMessages = styled.div<ChatMessagesProps>`
${({ isRTL }) => isRTL && `
padding-left: ${smPaddingX};
`}
@ -31,25 +39,6 @@ export const ChatMessages = styled.div<ChatMessagesProps>`
${({ isRTL }) => !isRTL && `
padding-right: ${smPaddingX};
`}
padding-bottom: ${smPaddingX};
user-select: text;
`;
export const MessageList = styled(ScrollboxVertical)`
flex-flow: column;
flex-shrink: 1;
right: 0 ${mdPaddingX} 0 0;
padding-top: 0;
outline-style: none;
overflow-x: hidden;
user-select: none;
[dir='rtl'] & {
margin: 0 0 0 auto;
padding: 0 0 0 ${mdPaddingX};
}
display: block;
`;
export const ButtonLoadMore = styled.button`
@ -70,8 +59,6 @@ export const UnreadButton = styled(ButtonElipsis)`
`;
export default {
MessageListWrapper,
MessageList,
UnreadButton,
ChatMessages,
};

View File

@ -0,0 +1,49 @@
import React, { useEffect, useState } from 'react';
import Styled from './styles';
import useSettings from '/imports/ui/services/settings/hooks/useSettings';
import { SETTINGS } from '/imports/ui/services/settings/enums';
import { ChatEvents } from '/imports/ui/core/enums/chat';
const ChatReplyIntention = () => {
const [username, setUsername] = useState<string>();
const [message, setMessage] = useState<string>();
useEffect(() => {
const handler = (e: Event) => {
if (e instanceof CustomEvent) {
setUsername(e.detail.username);
setMessage(e.detail.message);
}
};
window.addEventListener(ChatEvents.CHAT_REPLY_INTENTION, handler);
return () => {
window.removeEventListener(ChatEvents.CHAT_REPLY_INTENTION, handler);
};
}, []);
const { animations } = useSettings(SETTINGS.APPLICATION) as {
animations: boolean;
};
return (
<Styled.Container $hidden={!username || !message} $animations={animations}>
<Styled.Username>{username}</Styled.Username>
<Styled.Message>{message}</Styled.Message>
<Styled.CloseBtn
onClick={() => {
setMessage(undefined);
setUsername(undefined);
}}
icon="close"
ghost
circle
color="light"
size="sm"
/>
</Styled.Container>
);
};
export default ChatReplyIntention;

View File

@ -0,0 +1,62 @@
import styled, { css } from 'styled-components';
import {
colorBlueLight,
colorOffWhite,
} from '/imports/ui/stylesheets/styled-components/palette';
import Button from '/imports/ui/components/common/button/component';
const Container = styled.div<{ $hidden: boolean; $animations: boolean }>`
border-radius: 4px;
border-left: 4px solid ${colorBlueLight};
background-color: ${colorOffWhite};
position: relative;
overflow: hidden;
${({ $hidden }) => ($hidden
? css`
height: 0;
`
: css`
height: 4rem;
padding: 6px;
margin-right: 0.75rem;
margin-bottom: 0.25rem;
`
)}
${({ $animations }) => $animations
&& css`
transition-property: height;
transition-duration: 0.1s;
`}
`;
const Typography = styled.div`
line-height: 1rem;
font-size: 1rem;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
`;
const Username = styled(Typography)`
font-weight: bold;
color: ${colorBlueLight};
margin-bottom: 6px;
`;
const Message = styled(Typography)``;
// @ts-ignore
const CloseBtn = styled(Button)`
position: absolute;
top: 2px;
right: 2px;
`;
export default {
Container,
Username,
CloseBtn,
Message,
};

View File

@ -1,4 +1,5 @@
import React from 'react';
import { CircularProgress } from '@mui/material';
import ChatHeader from './chat-header/component';
import { layoutSelect, layoutSelectInput } from '../../layout/context';
import { Input, Layout } from '../../layout/layoutTypes';
@ -7,13 +8,13 @@ import ChatMessageListContainer from './chat-message-list/component';
import ChatMessageFormContainer from './chat-message-form/component';
import ChatTypingIndicatorContainer from './chat-typing-indicator/component';
import { PANELS, ACTIONS } from '/imports/ui/components/layout/enums';
import { CircularProgress } from '@mui/material';
import usePendingChat from '/imports/ui/core/local-states/usePendingChat';
import useChat from '/imports/ui/core/hooks/useChat';
import { Chat as ChatType } from '/imports/ui/Types/chat';
import { layoutDispatch } from '/imports/ui/components/layout/context';
import browserInfo from '/imports/utils/browserInfo';
import { GraphqlDataHookSubscriptionResponse } from '/imports/ui/Types/hook';
import ChatReplyIntention from './chat-reply-intention/component';
interface ChatProps {
isRTL: boolean;
@ -43,6 +44,7 @@ const Chat: React.FC<ChatProps> = ({ isRTL }) => {
<Styled.Chat isRTL={isRTL} isChrome={isChrome}>
<ChatHeader />
<ChatMessageListContainer />
<ChatReplyIntention />
<ChatMessageFormContainer />
<ChatTypingIndicatorContainer />
</Styled.Chat>

View File

@ -1,5 +1,7 @@
export const enum ChatEvents {
SENT_MESSAGE = 'sentMessage',
CHAT_FOCUS_MESSAGE_REQUEST = 'ChatFocusMessageRequest',
CHAT_REPLY_INTENTION = 'ChatReplyIntention',
}
export const enum ChatCommands {

View File

@ -569,6 +569,7 @@ export const meetingClientSettingsInitialValues: MeetingClientSettings = {
'p',
'strong',
],
toolbar: ['replies'],
},
userReaction: {
enabled: true,

View File

@ -7,7 +7,7 @@ interface Handlers {
stopObserving(): void;
}
const useStickyScroll = (el: HTMLElement | null) => {
const useStickyScroll = (stickyElement: HTMLElement | null, onResizeOf: HTMLElement | null) => {
const elHeight = useRef(0);
const timeout = useRef<ReturnType<typeof setTimeout>>();
const handlers = useRef<Handlers>({
@ -22,7 +22,10 @@ const useStickyScroll = (el: HTMLElement | null) => {
if (target instanceof HTMLElement) {
if (target.offsetHeight > elHeight.current) {
elHeight.current = target.offsetHeight;
target.scrollTop = target.scrollHeight + target.clientHeight;
if (stickyElement) {
// eslint-disable-next-line no-param-reassign
stickyElement.scrollTop = stickyElement.scrollHeight + stickyElement.clientHeight;
}
} else {
elHeight.current = 0;
}
@ -33,17 +36,17 @@ const useStickyScroll = (el: HTMLElement | null) => {
);
handlers.current.startObserving = useCallback(() => {
if (!el) return;
if (!onResizeOf) return;
clearTimeout(timeout.current);
observer.observe(el);
}, [el]);
observer.observe(onResizeOf);
}, [onResizeOf]);
handlers.current.stopObserving = useCallback(() => {
if (!el) return;
if (!onResizeOf) return;
timeout.current = setTimeout(() => {
observer.unobserve(el);
observer.unobserve(onResizeOf);
}, 500);
}, [el]);
}, [onResizeOf]);
useEffect(
() => () => {

View File

@ -53,8 +53,10 @@ const useStorageKey = (key: string, storage?: Storage[keyof Storage]) => {
export {
useStorageKey,
STORAGES,
};
export default {
useStorageKey,
STORAGES,
};

View File

@ -768,6 +768,7 @@ public:
# e.g.: disableEmojis: ['grin','laughing']
disableEmojis: []
allowedElements: ['a', 'code', 'em', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'ol', 'ul', 'p', 'strong']
toolbar: ['replies']
userReaction:
enabled: true
expire: 30

View File

@ -39,6 +39,7 @@
"app.chat.copyErr": "Copy chat transcript failed",
"app.chat.messageRead": "Message read by the recipient",
"app.chat.closePopup": "Close",
"app.chat.toolbar.reply": "Reply to message {0}",
"app.timer.toolTipTimerStopped": "The timer has stopped.",
"app.timer.toolTipTimerRunning": "The timer is running.",
"app.timer.toolTipStopwatchStopped": "The stopwatch has stopped.",