diff --git a/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-editing-warning/component.tsx b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-editing-warning/component.tsx new file mode 100644 index 0000000000..31b9d9f484 --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-editing-warning/component.tsx @@ -0,0 +1,70 @@ +import React, { useEffect, useState } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import { ChatEvents } from '/imports/ui/core/enums/chat'; +import Icon from '/imports/ui/components/common/icon/component'; +import { Highlighted, Left, Root } from './styles'; + +const intlMessages = defineMessages({ + editing: { + id: 'app.chat.toolbar.edit.editing', + description: '', + }, + cancel: { + id: 'app.chat.toolbar.edit.cancel', + description: '', + }, +}); + +const CANCEL_KEY_LABEL = 'esc'; + +const ChatEditingWarning = () => { + const [show, setShow] = useState(false); + const intl = useIntl(); + + useEffect(() => { + const handleEditingMessage = (e: Event) => { + if (e instanceof CustomEvent) { + setShow(true); + } + }; + + const handleCancelEditingMessage = (e: Event) => { + if (e instanceof CustomEvent) { + setShow(false); + } + }; + window.addEventListener(ChatEvents.CHAT_EDIT_REQUEST, handleEditingMessage); + window.addEventListener(ChatEvents.CHAT_CANCEL_EDIT_REQUEST, handleCancelEditingMessage); + + return () => { + window.removeEventListener(ChatEvents.CHAT_EDIT_REQUEST, handleEditingMessage); + window.removeEventListener(ChatEvents.CHAT_CANCEL_EDIT_REQUEST, handleCancelEditingMessage); + }; + }, []); + + if (!show) return null; + + const cancelMessage = intl.formatMessage(intlMessages.cancel, { key: CANCEL_KEY_LABEL }); + const editingMessage = intl.formatMessage(intlMessages.editing); + + return ( + + + + {editingMessage} + + + {cancelMessage.split(CANCEL_KEY_LABEL)[0]} +   + {CANCEL_KEY_LABEL} +   + {cancelMessage.split(CANCEL_KEY_LABEL)[1]} + + + {`${editingMessage} ${cancelMessage}`} + + + ); +}; + +export default ChatEditingWarning; diff --git a/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-editing-warning/styles.ts b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-editing-warning/styles.ts new file mode 100644 index 0000000000..bd33d4eff6 --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-editing-warning/styles.ts @@ -0,0 +1,34 @@ +import styled from 'styled-components'; +import { colorGrayLight } from '/imports/ui/stylesheets/styled-components/palette'; +import { xlPadding, xsPadding } from '/imports/ui/stylesheets/styled-components/general'; + +export const Root = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: nowrap; + color: ${colorGrayLight}; + + [dir='ltr'] & { + padding-right: ${xlPadding}; + } + + [dir='rtl'] & { + padding-left: ${xlPadding}; + } +`; + +export const Highlighted = styled.span` + font-weight: bold; +`; + +export const Left = styled.span` + display: flex; + align-items: center; + gap: ${xsPadding}; +`; + +export default { + Root, + Left, +}; diff --git a/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-form/component.tsx b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-form/component.tsx index 66d1be74f6..7822c21d16 100644 --- a/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-form/component.tsx +++ b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-form/component.tsx @@ -6,6 +6,7 @@ import React, { useRef, useMemo, } from 'react'; +import { useMutation } from '@apollo/client'; import TextareaAutosize from 'react-autosize-textarea'; import { ChatFormCommandsEnum } from 'bigbluebutton-html-plugin-sdk/dist/cjs/ui-commands/chat/form/enums'; import { FillChatFormCommandArguments } from 'bigbluebutton-html-plugin-sdk/dist/cjs/ui-commands/chat/form/types'; @@ -32,13 +33,13 @@ import useMeeting from '/imports/ui/core/hooks/useMeeting'; import ChatOfflineIndicator from './chat-offline-indicator/component'; import { ChatEvents } from '/imports/ui/core/enums/chat'; -import { useMutation } from '@apollo/client'; import { CHAT_SEND_MESSAGE, CHAT_SET_TYPING } from './mutations'; import Storage from '/imports/ui/services/storage/session'; import { indexOf, without } from '/imports/utils/array-utils'; import { GraphqlDataHookSubscriptionResponse } from '/imports/ui/Types/hook'; import { throttle } from '/imports/utils/throttle'; import logger from '/imports/startup/client/logger'; +import { CHAT_EDIT_MESSAGE_MUTATION } from '../chat-message-list/page/chat-message/mutations'; const CLOSED_CHAT_LIST_KEY = 'closedChatList'; const START_TYPING_THROTTLE_INTERVAL = 1000; @@ -113,6 +114,8 @@ const messages = defineMessages({ }, }); +type EditingMessage = { chatId: string; messageId: string, message: string }; + const ChatMessageForm: React.FC = ({ title, disabled, @@ -135,6 +138,7 @@ const ChatMessageForm: React.FC = ({ const emojiPickerButtonRef = useRef(null); const [isTextAreaFocused, setIsTextAreaFocused] = React.useState(false); const [repliedMessageId, setRepliedMessageId] = React.useState(); + const [editingMessage, setEditingMessage] = React.useState(null); const textAreaRef: RefObject = useRef(null); const { isMobile } = deviceInfo; const prevChatId = usePreviousValue(chatId); @@ -152,6 +156,10 @@ const ChatMessageForm: React.FC = ({ const [chatSendMessage, { loading: chatSendMessageLoading, error: chatSendMessageError, }] = useMutation(CHAT_SEND_MESSAGE); + const [ + chatEditMessage, + { loading: chatEditMessageLoading }, + ] = useMutation(CHAT_EDIT_MESSAGE_MUTATION); const CHAT_CONFIG = window.meetingClientSettings.public.chat; const PUBLIC_CHAT_ID = CHAT_CONFIG.public_id; @@ -284,17 +292,37 @@ const ChatMessageForm: React.FC = ({ }, [message]); useEffect(() => { - const handler = (e: Event) => { + const handleReplyIntention = (e: Event) => { if (e instanceof CustomEvent) { setRepliedMessageId(e.detail.messageId); textAreaRef.current?.textarea.focus(); } }; - window.addEventListener(ChatEvents.CHAT_REPLY_INTENTION, handler); + const handleEditingMessage = (e: Event) => { + if (e instanceof CustomEvent) { + if (textAreaRef.current) { + setMessage(e.detail.message); + setEditingMessage(e.detail); + } + } + }; + + const handleCancelEditingMessage = (e: Event) => { + if (e instanceof CustomEvent) { + setMessage(''); + setEditingMessage(null); + } + }; + + window.addEventListener(ChatEvents.CHAT_REPLY_INTENTION, handleReplyIntention); + window.addEventListener(ChatEvents.CHAT_EDIT_REQUEST, handleEditingMessage); + window.addEventListener(ChatEvents.CHAT_CANCEL_EDIT_REQUEST, handleCancelEditingMessage); return () => { - window.removeEventListener(ChatEvents.CHAT_REPLY_INTENTION, handler); + window.removeEventListener(ChatEvents.CHAT_REPLY_INTENTION, handleReplyIntention); + window.removeEventListener(ChatEvents.CHAT_EDIT_REQUEST, handleEditingMessage); + window.removeEventListener(ChatEvents.CHAT_CANCEL_EDIT_REQUEST, handleCancelEditingMessage); }; }, []); @@ -314,25 +342,30 @@ const ChatMessageForm: React.FC = ({ return; } - if (!chatSendMessageLoading) { + if (editingMessage && !chatEditMessageLoading) { + chatEditMessage({ + variables: { + chatId: editingMessage.chatId, + messageId: editingMessage.messageId, + chatMessageInMarkdownFormat: msg, + }, + }).then(() => { + window.dispatchEvent( + new CustomEvent(ChatEvents.CHAT_CANCEL_EDIT_REQUEST), + ); + }); + } else if (!chatSendMessageLoading) { chatSendMessage({ variables: { chatMessageInMarkdownFormat: msg, chatId: chatId === PUBLIC_CHAT_ID ? PUBLIC_GROUP_CHAT_ID : chatId, replyToMessageId: repliedMessageId, }, + }).then(() => { + window.dispatchEvent( + new CustomEvent(ChatEvents.CHAT_CANCEL_REPLY_INTENTION), + ); }); - - 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); @@ -459,65 +492,69 @@ const ChatMessageForm: React.FC = ({ ) : null} - { - window.dispatchEvent(new CustomEvent(PluginSdk.ChatFormUiDataNames.CHAT_INPUT_IS_FOCUSED, { - detail: { - value: true, - }, - })); - setIsTextAreaFocused(true); - }} - onBlur={() => { - window.dispatchEvent(new CustomEvent(PluginSdk.ChatFormUiDataNames.CHAT_INPUT_IS_FOCUSED, { - detail: { - value: false, - }, - })); - }} - onChange={handleMessageChange} - onKeyDown={handleMessageKeyDown} - onPaste={(e) => { e.stopPropagation(); }} - onCut={(e) => { e.stopPropagation(); }} - onCopy={(e) => { e.stopPropagation(); }} - async - /> - {ENABLE_EMOJI_PICKER ? ( - setShowEmojiPicker(!showEmojiPicker)} - icon="happy" - color="light" - ghost - type="button" - circle - hideLabel - label={intl.formatMessage(messages.emojiButtonLabel)} - data-test="emojiPickerButton" + + { + window.dispatchEvent(new CustomEvent(PluginSdk.ChatFormUiDataNames.CHAT_INPUT_IS_FOCUSED, { + detail: { + value: true, + }, + })); + setIsTextAreaFocused(true); + }} + onBlur={() => { + window.dispatchEvent(new CustomEvent(PluginSdk.ChatFormUiDataNames.CHAT_INPUT_IS_FOCUSED, { + detail: { + value: false, + }, + })); + }} + onChange={handleMessageChange} + onKeyDown={handleMessageKeyDown} + onPaste={(e) => { e.stopPropagation(); }} + onCut={(e) => { e.stopPropagation(); }} + onCopy={(e) => { e.stopPropagation(); }} + async /> - ) : null} - { }} - data-test="sendMessageButton" - /> + {ENABLE_EMOJI_PICKER ? ( + setShowEmojiPicker(!showEmojiPicker)} + icon="happy" + color="light" + ghost + type="button" + circle + hideLabel + label={intl.formatMessage(messages.emojiButtonLabel)} + data-test="emojiPickerButton" + /> + ) : null} + +
+ { }} + data-test="sendMessageButton" + /> +
{ error && ( diff --git a/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-form/styles.ts b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-form/styles.ts index 9ab78446e2..7f562d9995 100644 --- a/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-form/styles.ts +++ b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-form/styles.ts @@ -1,18 +1,18 @@ -/* eslint-disable @typescript-eslint/ban-ts-comment */ import styled from 'styled-components'; import { colorBlueLight, colorText, colorGrayLighter, - colorPrimary, colorDanger, colorGrayDark, + colorGrayLightest, } from '/imports/ui/stylesheets/styled-components/palette'; import { smPaddingX, smPaddingY, borderRadius, borderSize, + xsPadding, } from '/imports/ui/stylesheets/styled-components/general'; import { fontSizeBase } from '/imports/ui/stylesheets/styled-components/typography'; import TextareaAutosize from 'react-autosize-textarea'; @@ -43,13 +43,15 @@ const Form = styled.form` const Wrapper = styled.div` display: flex; flex-direction: row; + box-shadow: inset 0 0 0 1px ${colorGrayLightest}; + border-radius: 0.75rem; `; const Input = styled(TextareaAutosize)` flex: 1; background: #fff; background-clip: padding-box; - margin: 0; + margin: ${xsPadding} 0 ${xsPadding} ${xsPadding}; color: ${colorText}; -webkit-appearance: none; padding: calc(${smPaddingY} * 2.5) calc(${smPaddingX} * 1.25); @@ -60,8 +62,17 @@ const Input = styled(TextareaAutosize)` line-height: 1; min-height: 2.5rem; max-height: 10rem; - border: 1px solid ${colorGrayLighter}; - box-shadow: 0 0 0 1px ${colorGrayLighter}; + border: none; + box-shadow: none; + outline: none; + + [dir='ltr'] & { + border-radius: 0.75rem 0 0 0.75rem; + } + + [dir='rtl'] & { + border-radius: 0 0.75rem 0.75rem 0; + } &:disabled, &[disabled] { @@ -69,29 +80,22 @@ const Input = styled(TextareaAutosize)` opacity: .75; background-color: rgba(167,179,189,0.25); } - - &:focus { - border-radius: ${borderSize}; - box-shadow: 0 0 0 ${borderSize} ${colorBlueLight}, inset 0 0 0 1px ${colorPrimary}; - } - - &:hover, - &:active, - &:focus { - outline: transparent; - outline-style: dotted; - outline-width: ${borderSize}; - } `; // @ts-ignore - as button comes from JS, we can't provide its props const SendButton = styled(Button)` - margin:0 0 0 ${smPaddingX}; align-self: center; font-size: 0.9rem; + height: 100%; + + & > span { + height: 100%; + display: flex; + align-items: center; + border-radius: 0 0.75rem 0.75rem 0; + } [dir="rtl"] & { - margin: 0 ${smPaddingX} 0 0; -webkit-transform: scale(-1, 1); -moz-transform: scale(-1, 1); -ms-transform: scale(-1, 1); @@ -161,6 +165,28 @@ const EmojiPicker = styled(EmojiPickerComponent)` position: relative; `; +const InputWrapper = styled.div` + display: flex; + flex-direction: row; + flex-grow: 1; + min-width: 0; + z-index: 0; + + [dir='ltr'] & { + border-radius: 0.75rem 0 0 0.75rem; + margin-right: ${xsPadding}; + } + + [dir='rtl'] & { + border-radius: 0 0.75rem 0.75rem 0; + margin-left: ${xsPadding}; + } + + &:focus-within { + box-shadow: 0 0 0 ${xsPadding} ${colorBlueLight}; + } +`; + export default { Form, Wrapper, @@ -171,4 +197,5 @@ export default { EmojiPicker, EmojiPickerWrapper, ChatMessageError, + InputWrapper, }; diff --git a/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/component.tsx b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/component.tsx index 03c1c8d67f..e52fed8fab 100644 --- a/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/component.tsx +++ b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/component.tsx @@ -1,9 +1,9 @@ -/* eslint-disable @typescript-eslint/ban-ts-comment */ import React, { useCallback, useEffect, useState, useMemo, + KeyboardEventHandler, } from 'react'; import { makeVar, useMutation } from '@apollo/client'; import { defineMessages, useIntl } from 'react-intl'; @@ -26,6 +26,8 @@ import { import useReactiveRef from '/imports/ui/hooks/useReactiveRef'; import useStickyScroll from '/imports/ui/hooks/useStickyScroll'; import ChatReplyIntention from '../chat-reply-intention/component'; +import ChatEditingWarning from '../chat-editing-warning/component'; +import KEY_CODES from '/imports/utils/keyCodes'; const PAGE_SIZE = 50; @@ -104,6 +106,60 @@ const dispatchLastSeen = () => setTimeout(() => { } }, 500); +const roving = ( + event: React.KeyboardEvent, + changeState: (el: HTMLElement | null) => void, + elementsList: HTMLElement, + element: HTMLElement | null, +) => { + const numberOfChilds = elementsList.childElementCount; + + if ([KEY_CODES.ESCAPE, KEY_CODES.TAB].includes(event.keyCode)) { + changeState(null); + } + + if (event.keyCode === KEY_CODES.ARROW_DOWN) { + const firstElement = elementsList.firstChild as HTMLElement; + let elRef = element && numberOfChilds > 1 ? (element.nextSibling as HTMLElement) : firstElement; + + while (elRef && elRef.dataset.focusable !== 'true' && elRef.nextSibling) { + elRef = elRef.nextSibling as HTMLElement; + } + + elRef = (elRef && elRef.dataset.focusable === 'true') ? elRef : firstElement; + changeState(elRef); + } + + if (event.keyCode === KEY_CODES.ARROW_UP) { + const lastElement = elementsList.lastChild as HTMLElement; + let elRef = element ? (element.previousSibling as HTMLElement) : lastElement; + + while (elRef && elRef.dataset.focusable !== 'true' && elRef.previousSibling) { + elRef = elRef.previousSibling as HTMLElement; + } + + elRef = (elRef && elRef.dataset.focusable === 'true') ? elRef : lastElement; + changeState(elRef); + } + + if ([KEY_CODES.SPACE, KEY_CODES.ENTER].includes(event.keyCode)) { + const elRef = document.activeElement?.firstChild as HTMLElement; + changeState(elRef); + } + + if ([KEY_CODES.ARROW_RIGHT].includes(event.keyCode)) { + if (element?.dataset) { + const { sequence } = element.dataset; + + window.dispatchEvent(new CustomEvent(ChatEvents.CHAT_KEYBOARD_FOCUS_MESSAGE_REQUEST, { + detail: { + sequence, + }, + })); + } + } +}; + const ChatMessageList: React.FC = ({ totalPages, chatId, @@ -126,6 +182,7 @@ const ChatMessageList: React.FC = ({ const [userLoadedBackUntilPage, setUserLoadedBackUntilPage] = useState(null); const [lastMessageCreatedAt, setLastMessageCreatedAt] = useState(''); const [followingTail, setFollowingTail] = React.useState(true); + const [selectedMessage, setSelectedMessage] = React.useState(null); const { childRefProxy: sentinelRefProxy, intersecting: isSentinelVisible, @@ -259,6 +316,17 @@ const ChatMessageList: React.FC = ({ : Math.max(totalPages - 2, 0); const pagesToLoad = (totalPages - firstPageToLoad) || 1; + const rove: KeyboardEventHandler = (e) => { + if (messageListRef.current) { + roving( + e, + setSelectedMessage, + messageListRef.current, + selectedMessage, + ); + } + }; + return ( <> { @@ -276,7 +344,15 @@ const ChatMessageList: React.FC = ({ isRTL={isRTL} ref={messageListContainerRefProxy} > -
+
{ + setSelectedMessage(null); + }} + > {userLoadedBackUntilPage ? ( { @@ -301,6 +377,9 @@ const ChatMessageList: React.FC = ({ chatId={chatId} markMessageAsSeen={markMessageAsSeen} scrollRef={messageListContainerRefProxy} + focusedId={selectedMessage?.dataset.sequence + ? Number.parseInt(selectedMessage?.dataset.sequence, 10) + : null} /> ); })} @@ -311,10 +390,13 @@ const ChatMessageList: React.FC = ({ height: 1, background: 'none', }} + tabIndex={-1} + aria-hidden /> , renderUnreadNotification, - , + , + , ] } diff --git a/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/component.tsx b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/component.tsx index 49b7bf2a41..182fd514ba 100644 --- a/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/component.tsx +++ b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/component.tsx @@ -19,7 +19,9 @@ import { MessageItemWrapper, Container, DeleteMessage, - ChatEditTime, + ChatHeading, + EditLabel, + EditLabelWrapper, } from './styles'; import { ChatEvents, ChatMessageType } from '/imports/ui/core/enums/chat'; import MessageReadConfirmation from './message-read-confirmation/component'; @@ -27,7 +29,6 @@ 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'; -import ChatEditMessageForm from './message-edit-form/component'; import useMeeting from '/imports/ui/core/hooks/useMeeting'; import useCurrentUser from '/imports/ui/core/hooks/useCurrentUser'; import { layoutSelect } from '/imports/ui/components/layout/context'; @@ -36,6 +37,15 @@ 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'; +import Icon from '/imports/ui/components/common/icon/component'; +import { colorBlueLightestChannel } from '/imports/ui/stylesheets/styled-components/palette'; +import { + useIsReplyChatMessageEnabled, + useIsChatMessageReactionsEnabled, + useIsEditChatMessageEnabled, + useIsDeleteChatMessageEnabled, +} from '/imports/ui/services/features'; +import ChatMessageNotificationContent from './message-content/notification-content/component'; interface ChatMessageProps { message: Message; @@ -45,6 +55,8 @@ interface ChatMessageProps { scrollRef: React.RefObject; markMessageAsSeen: (message: Message) => void; messageReadFeedbackEnabled: boolean; + focused: boolean; + keyboardFocused: boolean; } const intlMessages = defineMessages({ @@ -76,6 +88,10 @@ const intlMessages = defineMessages({ id: 'app.chat.deleteMessage', description: '', }, + edited: { + id: 'app.chat.toolbar.edit.edited', + description: 'edited message label', + }, }); function isInViewport(el: HTMLDivElement) { @@ -98,6 +114,8 @@ const ChatMesssage: React.FC = ({ setMessagesRequestedFromPlugin, markMessageAsSeen, messageReadFeedbackEnabled, + focused, + keyboardFocused, }) => { const idChatOpen: string = layoutSelect((i: Layout) => i.idChatOpen); const { data: meeting } = useMeeting((m) => ({ @@ -122,7 +140,6 @@ const ChatMesssage: React.FC = ({ }, [message, messageRef]); const messageContentRef = React.createRef(); 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(null); @@ -150,7 +167,6 @@ const ChatMesssage: React.FC = ({ }); }, []); - const CHAT_TOOLBAR_CONFIG = window.meetingClientSettings.public.chat.toolbar; const isModerator = currentUser?.isModerator; const isPublicChat = chat?.public; const isLocked = currentUser?.locked || currentUser?.userLockSettings?.disablePublicChat; @@ -168,11 +184,17 @@ const ChatMesssage: React.FC = ({ } } - const hasToolbar = CHAT_TOOLBAR_CONFIG.length > 0 - && message.deletedAt === null - && !editing - && !!message.user - && !locked; + const CHAT_REPLY_ENABLED = useIsReplyChatMessageEnabled(); + const CHAT_REACTIONS_ENABLED = useIsChatMessageReactionsEnabled(); + const CHAT_EDIT_ENABLED = useIsEditChatMessageEnabled(); + const CHAT_DELETE_ENABLED = useIsDeleteChatMessageEnabled(); + + const hasToolbar = !!message.user && [ + CHAT_REPLY_ENABLED, + CHAT_REACTIONS_ENABLED, + CHAT_EDIT_ENABLED, + CHAT_DELETE_ENABLED, + ].some((config) => config); const startScrollAnimation = (timestamp: number) => { if (scrollRef.current && containerRef.current) { @@ -184,7 +206,7 @@ const ChatMesssage: React.FC = ({ }; useEffect(() => { - const handler = (e: Event) => { + const handleFocusMessageRequest = (e: Event) => { if (e instanceof CustomEvent) { if (e.detail.sequence === message.messageSequence) { requestAnimationFrame(startScrollAnimation); @@ -192,10 +214,27 @@ const ChatMesssage: React.FC = ({ } }; - window.addEventListener(ChatEvents.CHAT_FOCUS_MESSAGE_REQUEST, handler); + const handleChatEditRequest = (e: Event) => { + if (e instanceof CustomEvent) { + const editing = e.detail.messageId === message.messageId; + setEditing(editing); + } + }; + + const handleCancelChatEditRequest = (e: Event) => { + if (e instanceof CustomEvent) { + setEditing(false); + } + }; + + window.addEventListener(ChatEvents.CHAT_FOCUS_MESSAGE_REQUEST, handleFocusMessageRequest); + window.addEventListener(ChatEvents.CHAT_EDIT_REQUEST, handleChatEditRequest); + window.addEventListener(ChatEvents.CHAT_CANCEL_EDIT_REQUEST, handleCancelChatEditRequest); return () => { - window.removeEventListener(ChatEvents.CHAT_FOCUS_MESSAGE_REQUEST, handler); + window.removeEventListener(ChatEvents.CHAT_FOCUS_MESSAGE_REQUEST, handleFocusMessageRequest); + window.removeEventListener(ChatEvents.CHAT_EDIT_REQUEST, handleChatEditRequest); + window.removeEventListener(ChatEvents.CHAT_CANCEL_EDIT_REQUEST, handleCancelChatEditRequest); }; }, []); @@ -209,7 +248,7 @@ const ChatMesssage: React.FC = ({ if (!containerRef.current) return; const value = (timestamp - animationInitialTimestamp.current) / ANIMATION_DURATION; if (value < 1) { - containerRef.current.style.backgroundColor = `rgba(243, 246, 249, ${1 - value})`; + containerRef.current.style.backgroundColor = `rgb(${colorBlueLightestChannel} / ${1 - value})`; requestAnimationFrame(animate); } else { containerRef.current.style.backgroundColor = 'unset'; @@ -258,6 +297,7 @@ const ChatMesssage: React.FC = ({ const formattedTime = intl.formatTime(dateTime, { hour: 'numeric', minute: 'numeric', + hour12: false, }); const editTime = message.editedAt ? new Date(message.editedAt) : null; const deleteTime = message.deletedAt ? new Date(message.deletedAt) : null; @@ -266,12 +306,15 @@ const ChatMesssage: React.FC = ({ const clearMessage = `${msgTime} ${intl.formatMessage(intlMessages.chatClear)}`; const messageContent: { - name: string, - color: string, - isModerator: boolean, - isPresentationUpload?: boolean, - component: React.ReactElement, - avatarIcon?: string, + name: string; + color: string; + isModerator: boolean; + isPresentationUpload?: boolean; + component: React.ReactNode; + avatarIcon?: string; + isSystemSender?: boolean; + showAvatar: boolean; + showHeading: boolean; } = useMemo(() => { switch (message.messageType) { case ChatMessageType.POLL: @@ -283,6 +326,8 @@ const ChatMesssage: React.FC = ({ ), avatarIcon: 'icon-bbb-polling', + showAvatar: true, + showHeading: true, }; case ChatMessageType.PRESENTATION: return { @@ -296,6 +341,8 @@ const ChatMesssage: React.FC = ({ /> ), avatarIcon: 'icon-bbb-download', + showAvatar: true, + showHeading: true, }; case ChatMessageType.CHAT_CLEAR: return { @@ -304,26 +351,28 @@ const ChatMesssage: React.FC = ({ isModerator: false, isSystemSender: true, component: ( - ), + showAvatar: false, + showHeading: false, }; case ChatMessageType.BREAKOUT_ROOM: return { name: message.senderName, color: '#0F70D7', isModerator: true, - isSystemSender: true, + isSystemSender: false, component: ( ), + showAvatar: true, + showHeading: true, }; case ChatMessageType.API: return { @@ -332,30 +381,31 @@ const ChatMesssage: React.FC = ({ isModerator: true, isSystemSender: true, component: ( - ), + showAvatar: false, + showHeading: false, }; case ChatMessageType.USER_AWAY_STATUS_MSG: { const { away } = JSON.parse(message.messageMetadata); const awayMessage = (away) - ? `${intl.formatMessage(intlMessages.userAway)}` - : `${intl.formatMessage(intlMessages.userNotAway)}`; + ? intl.formatMessage(intlMessages.userAway, { user: message.senderName }) + : intl.formatMessage(intlMessages.userNotAway, { user: message.senderName }); return { name: message.senderName, color: '#0F70D7', isModerator: true, isSystemSender: true, component: ( - ), + showAvatar: false, + showHeading: false, }; } case ChatMessageType.PLUGIN: { @@ -364,13 +414,14 @@ const ChatMesssage: React.FC = ({ color: message.user?.color, isModerator: message.user?.isModerator, isSystemSender: false, + showAvatar: true, + showHeading: true, component: currentPluginMessageMetadata.custom - ? (<>) + ? null : ( ), }; @@ -381,104 +432,115 @@ const ChatMesssage: React.FC = ({ name: message.user?.name, color: message.user?.color, isModerator: message.user?.isModerator, - isSystemSender: ChatMessageType.BREAKOUT_ROOM, + isSystemSender: false, + showAvatar: true, + showHeading: true, component: ( ), }; } }, [message.message]); + + const shouldRenderAvatar = messageContent.showAvatar + && !sameSender + && !isCustomPluginMessage; + + const shouldRenderHeader = messageContent.showHeading + && !sameSender + && !isCustomPluginMessage; + return ( - + - {hasToolbar && ( - { - sendReaction(emoji.native); - setIsToolbarReactionPopoverOpen(false); - }} - onEditRequest={() => { - setEditing(true); - }} - onMenuOpenChange={setIsToolbarMenuOpen} - menuIsOpen={isToolbarMenuOpen} - onReactionPopoverOpenChange={setIsToolbarReactionPopoverOpen} - reactionPopoverIsOpen={isToolbarReactionPopoverOpen} - /> - )} - {((!message?.user || !sameSender) && ( - message.messageType !== ChatMessageType.USER_AWAY_STATUS_MSG - && message.messageType !== ChatMessageType.API - && message.messageType !== ChatMessageType.CHAT_CLEAR - && !isCustomPluginMessage) - ) && ( - - {!messageContent.avatarIcon ? ( - !message.user || (message.user?.avatar.length === 0 ? messageContent.name.toLowerCase().slice(0, 2) : '') - ) : ( - + { + sendReaction(emoji.native); + setIsToolbarReactionPopoverOpen(false); + }} + onReactionPopoverOpenChange={setIsToolbarReactionPopoverOpen} + reactionPopoverIsOpen={isToolbarReactionPopoverOpen} + chatDeleteEnabled={CHAT_DELETE_ENABLED} + chatEditEnabled={CHAT_EDIT_ENABLED} + chatReactionsEnabled={CHAT_REACTIONS_ENABLED} + chatReplyEnabled={CHAT_REPLY_ENABLED} + /> + {(shouldRenderAvatar || shouldRenderHeader) && ( + + {shouldRenderAvatar && ( + + {!messageContent.avatarIcon ? ( + !message.user || (message.user?.avatar.length === 0 ? messageContent.name.toLowerCase().slice(0, 2) : '') + ) : ( + + )} + )} - - )} - {!editing && ( - - {message.messageType !== ChatMessageType.CHAT_CLEAR - && !isCustomPluginMessage - && ( + {shouldRenderHeader && ( )} - {message.replyToMessage && ( + + )} + + {message.replyToMessage && !deleteTime && ( )} {!deleteTime && ( - + {messageContent.component} {messageReadFeedbackEnabled && ( = ({ )} )} - {!deleteTime && editTime && ( - - {`(${intl.formatMessage(intlMessages.editTime, { 0: intl.formatTime(editTime) })})`} - + {!deleteTime && editTime && sameSender && ( + + + + {intl.formatMessage(intlMessages.edited)} + + )} {deleteTime && ( {intl.formatMessage(intlMessages.deleteMessage, { 0: message.deletedBy?.name })} )} - {!deleteTime && ( - - )} - )} - {editing && ( - setEditing(false)} - onAfterSubmit={() => setEditing(false)} - sameSender={message?.user ? sameSender : false} - /> + {!deleteTime && ( + )} @@ -528,7 +582,9 @@ function areChatMessagesEqual(prevProps: ChatMessageProps, nextProps: ChatMessag && prevMessage?.user?.currentlyInMeeting === nextMessage?.user?.currentlyInMeeting && prevMessage?.recipientHasSeen === nextMessage.recipientHasSeen && prevMessage?.message === nextMessage.message - && prevMessage?.reactions?.length === nextMessage?.reactions?.length; + && prevMessage?.reactions?.length === nextMessage?.reactions?.length + && prevProps.focused === nextProps.focused + && prevProps.keyboardFocused === nextProps.keyboardFocused; } export default memo(ChatMesssage, areChatMessagesEqual); diff --git a/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/message-content/notification-content/component.tsx b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/message-content/notification-content/component.tsx new file mode 100644 index 0000000000..491b5caf4e --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/message-content/notification-content/component.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import Styled from './styles'; + +interface ChatMessageNotificationContentProps { + text: string; + iconName?: string; +} + +const ChatMessageNotificationContent: React.FC = (props) => { + const { text, iconName } = props; + return ( + + {iconName && } + + {text} + + + ); +}; + +export default ChatMessageNotificationContent; diff --git a/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/message-content/notification-content/styles.ts b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/message-content/notification-content/styles.ts new file mode 100644 index 0000000000..642030d66c --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/message-content/notification-content/styles.ts @@ -0,0 +1,35 @@ +import styled from 'styled-components'; +import BaseIcon from '/imports/ui/components/common/icon/component'; +import { colorGray } from '/imports/ui/stylesheets/styled-components/palette'; +import { $3xlPadding, smPadding } from '/imports/ui/stylesheets/styled-components/general'; + +export const Root = styled.div` + color: ${colorGray}; + padding: 0 ${$3xlPadding}; + width: 100%; + text-align: center; +`; + +export const Icon = styled(BaseIcon)` + vertical-align: baseline; + + [dir='ltr'] & { + margin-right: ${smPadding}; + } + + [dir='rtl'] & { + margin-left: ${smPadding}; + } +`; + +export const Typography = styled.span` + vertical-align: baseline; + overflow-wrap: break-word; + white-space: pre-wrap; +`; + +export default { + Root, + Icon, + Typography, +}; diff --git a/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/message-content/text-content/component.tsx b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/message-content/text-content/component.tsx index 8610f1ade1..5dd5e322dc 100644 --- a/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/message-content/text-content/component.tsx +++ b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/message-content/text-content/component.tsx @@ -5,19 +5,17 @@ import Styled from './styles'; interface ChatMessageTextContentProps { text: string; emphasizedMessage: boolean; - systemMsg: boolean; dataTest?: string | null; } const ChatMessageTextContent: React.FC = ({ text, emphasizedMessage, - systemMsg, dataTest = 'messageContent', }) => { const { allowedElements } = window.meetingClientSettings.public.chat; return ( - + ` flex-direction: column; color: ${colorText}; word-break: break-word; - ${({ systemMsg }) => systemMsg && ` - background: ${systemMessageBackgroundColor}; - border: 1px solid ${systemMessageBorderColor}; - border-radius: ${borderRadius}; - font-weight: ${btnFontWeight}; - padding: ${fontSizeBase}; - text-color: #1f252b; - margin-top: 0; - margin-bottom: 0; - overflow-wrap: break-word; - `} + ${({ emphasizedMessage }) => emphasizedMessage && ` font-weight: bold; `} diff --git a/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/message-edit-form/component.tsx b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/message-edit-form/component.tsx deleted file mode 100644 index 2202c46483..0000000000 --- a/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/message-edit-form/component.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import React, { useEffect } from 'react'; -import { defineMessages, useIntl } from 'react-intl'; -import { checkText } from 'smile2emoji'; -import { useMutation } from '@apollo/client'; -import Button from '/imports/ui/components/common/button/component'; -import { textToMarkdown } from '/imports/ui/components/chat/chat-graphql/chat-message-form/service'; -import { CHAT_EDIT_MESSAGE_MUTATION } from '../mutations'; -import Styled from './styles'; -import logger from '/imports/startup/client/logger'; - -const intlMessages = defineMessages({ - errorMaxMessageLength: { - id: 'app.chat.errorMaxMessageLength', - }, - errorOnUpdateMessage: { - id: 'app.chat.errorOnUpdateMessage', - }, -}); - -interface ChatEditMessageFormProps { - onCancel(): void; - onAfterSubmit(): void; - initialMessage: string; - chatId: string; - messageId: string; - sameSender: boolean; -} - -const ChatEditMessageForm: React.FC = (props) => { - const { - onCancel, chatId, initialMessage, messageId, onAfterSubmit, sameSender, - } = props; - const [editedMessage, setEditedMessage] = React.useState(initialMessage); - const [hasErrors, setHasErrors] = React.useState(false); - const [error, setError] = React.useState(null); - const intl = useIntl(); - - const [chatEditMessage, { - loading: chatEditMessageLoading, error: chatEditMessageError, - }] = useMutation(CHAT_EDIT_MESSAGE_MUTATION); - - const CHAT_CONFIG = window.meetingClientSettings.public.chat; - const AUTO_CONVERT_EMOJI = CHAT_CONFIG.autoConvertEmoji; - const MIN_MESSAGE_LENGTH = CHAT_CONFIG.min_message_length; - const MAX_MESSAGE_LENGTH = CHAT_CONFIG.max_message_length; - const PUBLIC_CHAT_ID = CHAT_CONFIG.public_id; - const PUBLIC_GROUP_CHAT_ID = CHAT_CONFIG.public_group_id; - - useEffect(() => { - if (chatEditMessageError && error == null) { - logger.error({ - logCode: 'update_message_error', - extraInfo: { - errorName: chatEditMessageError.name, - errorMessage: chatEditMessageError.message, - }, - }, 'Updating chat message failed'); - setError(intl.formatMessage(intlMessages.errorOnUpdateMessage)); - } - }, [chatEditMessageError]); - - const handleMessageChange: React.ChangeEventHandler = (e) => { - let newMessage = null; - let newError = null; - if (AUTO_CONVERT_EMOJI) { - newMessage = checkText(e.target.value); - } else { - newMessage = e.target.value; - } - - if (newMessage.length > MAX_MESSAGE_LENGTH) { - newError = intl.formatMessage( - intlMessages.errorMaxMessageLength, - { 0: MAX_MESSAGE_LENGTH }, - ); - newMessage = newMessage.substring(0, MAX_MESSAGE_LENGTH); - } - setEditedMessage(newMessage); - setError(newError); - }; - - const handleSubmit = (e: React.FormEvent | React.KeyboardEvent | Event) => { - e.preventDefault(); - - const msg = textToMarkdown(editedMessage); - - if (msg.length < MIN_MESSAGE_LENGTH || chatEditMessageLoading) return; - - if (msg.length > MAX_MESSAGE_LENGTH) { - setHasErrors(true); - return; - } - - if (!chatEditMessageLoading) { - chatEditMessage({ - variables: { - chatMessageInMarkdownFormat: msg, - chatId: chatId === PUBLIC_CHAT_ID ? PUBLIC_GROUP_CHAT_ID : chatId, - messageId, - }, - }); - } - - setError(null); - setHasErrors(false); - - onAfterSubmit(); - }; - - const handleMessageKeyDown: React.KeyboardEventHandler = (e) => { - if (e.code === 'Enter' && !e.shiftKey) { - e.preventDefault(); - - const event = new Event('submit', { - bubbles: true, - cancelable: true, - }); - handleSubmit(event); - } - }; - - return ( -
- -
- e.stopPropagation()} - style={{ - width: '100%', - minWidth: 0, - }} - /> - {error && ( - - {error} - - )} -
- -