diff --git a/bigbluebutton-html5/imports/ui/Types/meeting.ts b/bigbluebutton-html5/imports/ui/Types/meeting.ts index ba03e4ec12..dd208db2e6 100644 --- a/bigbluebutton-html5/imports/ui/Types/meeting.ts +++ b/bigbluebutton-html5/imports/ui/Types/meeting.ts @@ -11,6 +11,13 @@ export interface LockSettings { webcamsOnlyForModerator: boolean; } +export interface WelcomeSettings { + welcomeMsg: string; + modOnlyMessage: string; + welcomeMsgTemplate: string; + meetingId: string; +} + export interface UsersPolicies { allowModsToEjectCameras: boolean; allowModsToUnmuteUsers: boolean; diff --git a/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-header/chat-actions/component.tsx b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-header/chat-actions/component.tsx new file mode 100644 index 0000000000..a44820d739 --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-header/chat-actions/component.tsx @@ -0,0 +1,164 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import BBBMenu from '/imports/ui/components/common/menu/component'; +import { layoutSelect } from '/imports/ui/components/layout/context'; +import { Layout } from '/imports/ui/components/layout/layoutTypes'; +import { useLazyQuery } from '@apollo/client'; +import { GET_CHAT_MESSAGE_HISTORY, GET_PERMISSIONS, getChatMessageHistory, getPermissions } from './queries'; +import { uid } from 'radash'; +import Button from '/imports/ui/components/common/button/component'; +import { clearPublicChatHistory, generateExportedMessages } from './services' +import { getDateString } from '/imports/utils/string-utils'; + +const CHAT_CONFIG = Meteor.settings.public.chat; +const ENABLE_SAVE_AND_COPY_PUBLIC_CHAT = CHAT_CONFIG.enableSaveAndCopyPublicChat; + +const intlMessages = defineMessages({ + clear: { + id: 'app.chat.dropdown.clear', + description: 'Clear button label', + }, + save: { + id: 'app.chat.dropdown.save', + description: 'Clear button label', + }, + copy: { + id: 'app.chat.dropdown.copy', + description: 'Copy button label', + }, + copySuccess: { + id: 'app.chat.copySuccess', + description: 'aria success alert', + }, + copyErr: { + id: 'app.chat.copyErr', + description: 'aria error alert', + }, + options: { + id: 'app.chat.dropdown.options', + description: 'Chat Options', + }, +}); + +export const ChatActions: React.FC = () => { + const intl = useIntl(); + const isRTL = layoutSelect((i: Layout) => i.isRTL); + const uniqueIdsRef = useRef([uid(1), uid(2), uid(3)]); + const downloadOrCopyRef = useRef<'download' | 'copy' | null>(null); + const [userIsModerator, setUserIsmoderator] = useState(false); + const [meetingIsBreakout, setMeetingIsBreakout] = useState(false); + const [ + getChatMessageHistory, + { + loading: loadingHistory, + error: errorHistory, + data: dataHistory, + }] = useLazyQuery(GET_CHAT_MESSAGE_HISTORY, { fetchPolicy: 'no-cache' }); + + const [ + getPermissions, + { + loading: loadingPermissions, + error: errorPermissions, + data: dataPermissions, + }] = useLazyQuery(GET_PERMISSIONS, { fetchPolicy: 'cache-and-network' }); + + useEffect(() => { + if (dataHistory) { + console.log('dataHistory', dataHistory); + const exportedString = generateExportedMessages( + dataHistory.chat_message_public, + dataHistory.user_welcomeMsgs[0], + ); + if (downloadOrCopyRef.current === 'download') { + const link = document.createElement('a'); + const mimeType = 'text/plain'; + link.setAttribute('download', `bbb-${dataHistory.meeting[0].name}[public-chat]_${getDateString()}.txt`); + link.setAttribute( + 'href', + `data: ${mimeType};charset=utf-8,` + + `${encodeURIComponent(exportedString)}`, + ); + link.dispatchEvent(new MouseEvent('click', { bubbles: false, cancelable: true, view: window })); + downloadOrCopyRef.current = null; + } else if (downloadOrCopyRef.current === 'copy') { + navigator.clipboard.writeText(exportedString); + downloadOrCopyRef.current = null; + } + } + }, [dataHistory]); + + useEffect(() => { + if (dataPermissions) { + setUserIsmoderator(dataPermissions.user_current[0].isModerator); + setMeetingIsBreakout(dataPermissions.meeting[0].isBreakout); + } + }, [dataPermissions]); + + const actions = useMemo(()=>{ + const dropdownActions = [ + { + key: uniqueIdsRef.current[0], + enable: ENABLE_SAVE_AND_COPY_PUBLIC_CHAT, + icon: 'download', + dataTest: 'chatSave', + label: intl.formatMessage(intlMessages.save), + onClick: () => { + getChatMessageHistory(); + downloadOrCopyRef.current = 'download'; + }, + }, + { + key: uniqueIdsRef.current[1], + enable: ENABLE_SAVE_AND_COPY_PUBLIC_CHAT, + icon: 'copy', + id: 'clipboardButton', + dataTest: 'chatCopy', + label: intl.formatMessage(intlMessages.copy), + onClick: () => { + getChatMessageHistory(); + downloadOrCopyRef.current = 'copy'; + }, + }, + { + key: uniqueIdsRef.current[2], + enable: userIsModerator && !meetingIsBreakout, + icon: 'download', + dataTest: 'chatClear', + label: intl.formatMessage(intlMessages.clear), + onClick: () => clearPublicChatHistory(), + }, + ]; + return dropdownActions.filter((action) => action.enable); + },[userIsModerator, meetingIsBreakout]); + if (errorHistory) return

Error loading chat history: {JSON.stringify(errorHistory)}

; + if (errorPermissions) return

Error loading permissions: {JSON.stringify(errorPermissions)}

; + return ( + { + getPermissions(); + }} + data-test="chatOptionsMenu" + /> + } + opts={{ + id: 'chat-options-dropdown-menu', + keepMounted: true, + transitionDuration: 0, + elevation: 3, + getContentAnchorEl: null, + fullwidth: 'true', + anchorOrigin: { vertical: 'bottom', horizontal: isRTL ? 'right' : 'left' }, + transformOrigin: { vertical: 'top', horizontal: isRTL ? 'right' : 'left' }, + }} + actions={actions} + /> + ); +} \ No newline at end of file diff --git a/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-header/chat-actions/queries.ts b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-header/chat-actions/queries.ts new file mode 100644 index 0000000000..103e7315e3 --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-header/chat-actions/queries.ts @@ -0,0 +1,49 @@ +import { gql } from "@apollo/client"; +import { User} from '/imports/ui/Types/user' +import { Meeting } from "/imports/ui/Types/meeting"; +import { Message } from "/imports/ui/Types/message"; + +export type getChatMessageHistory = { + chat_message_public: Array + meeting: Array; + user_welcomeMsgs: Array<{welcomeMsg: string, welcomeMsgForModerators: string | null}>; +}; + +export type getPermissions = { + user_current: Array, + meeting: Array<{isBreakout: boolean}> +}; + +export const GET_CHAT_MESSAGE_HISTORY = gql` +query getChatMessageHistory { + chat_message_public(order_by: {createdTime: asc}) { + message + messageId + createdTime + user { + userId + name + role + } + } + meeting { + name + } + user_welcomeMsgs { + welcomeMsg + welcomeMsgForModerators + } +} +`; + +export const GET_PERMISSIONS = gql` +query getPermissions { + user_current { + isModerator + } + meeting { + isBreakout + name + } +} +`; diff --git a/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-header/chat-actions/services.ts b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-header/chat-actions/services.ts new file mode 100644 index 0000000000..e3efa2b6fc --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-header/chat-actions/services.ts @@ -0,0 +1,40 @@ +import { Message } from "/imports/ui/Types/message"; +import { makeCall } from "/imports/ui/services/api"; +import { stripTags, unescapeHtml } from '/imports/utils/string-utils'; + +export const htmlDecode = (input: string) => { + const replacedBRs = input.replaceAll('
', '\n'); + return unescapeHtml(stripTags(replacedBRs)); +}; + +export const generateExportedMessages = (messages: Array, welcomeSettings: {welcomeMsg: string, welcomeMsgForModerators: string | null} ): string => { + const welcomeMessage = htmlDecode(welcomeSettings.welcomeMsg); + const modOnlyMessage = welcomeSettings.welcomeMsgForModerators && htmlDecode(welcomeSettings.welcomeMsgForModerators); + const systemMessages = `${welcomeMessage ? `system: ${welcomeMessage}`: ''}\n ${modOnlyMessage ? `system: ${modOnlyMessage}`: ''}\n` + + const text = messages.reduce((acc, message) => { + const date = new Date(message.createdTime); + const hour = date.getHours().toString().padStart(2, 0); + const min = date.getMinutes().toString().padStart(2, 0); + const hourMin = `[${hour}:${min}]`; + const userName = message.user.name; + const messageText = htmlDecode(message.message); + return `${acc}${hourMin} [${userName} : ${message.user.role}]: ${messageText}\n`; + },welcomeMessage? systemMessages : ''); + return text; +}; + +export const getDateString = (date = new Date()) => { + const hours = date.getHours().toString().padStart(2, 0); + const minutes = date.getMinutes().toString().padStart(2, 0); + const month = (date.getMonth() + 1).toString().padStart(2, 0); + const dayOfMonth = date.getDate().toString().padStart(2, 0); + const time = `${hours}-${minutes}`; + const dateString = `${date.getFullYear()}-${month}-${dayOfMonth}_${time}`; + return dateString; +}; + + +// TODO: Make action using mutations +export const clearPublicChatHistory = () => (makeCall('clearPublicChatHistory')); + diff --git a/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-header/component.tsx b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-header/component.tsx new file mode 100644 index 0000000000..0639157b08 --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-header/component.tsx @@ -0,0 +1,124 @@ +import React from 'react'; +import Header from '/imports/ui/components/common/control-header/component'; +import { useQuery } from '@apollo/client'; +import { GET_CHAT_DATA, GetChatDataResponse } from './queries'; +import { defineMessages, useIntl } from 'react-intl'; +import { closePrivateChat } from './services'; +import { layoutSelect, layoutDispatch } from '../../../layout/context'; +import { useShortcutHelp } from '../../../shortcut-help/useShortcutHelp'; +import { Layout } from '../../../layout/layoutTypes'; +import { ACTIONS, PANELS } from '../../../layout/enums'; +import { ChatActions } from './chat-actions/component'; + +interface ChatHeaderProps { + chatId: string; + isPublicChat: boolean; + title: string; +} + +const intlMessages = defineMessages({ + closeChatLabel: { + id: 'app.chat.closeChatLabel', + description: 'aria-label for closing chat button', + }, + hideChatLabel: { + id: 'app.chat.hideChatLabel', + description: 'aria-label for hiding chat button', + }, + titlePublic: { + id: 'app.chat.titlePublic', + description: 'Public chat title', + }, + titlePrivate: { + id: 'app.chat.titlePrivate', + description: 'Private chat title', + }, +}); + +const ChatHeader: React.FC = ({ chatId, isPublicChat, title}) => { + + const HIDE_CHAT_AK = useShortcutHelp('hideprivatechat'); + const CLOSE_CHAT_AK = useShortcutHelp('closeprivatechat'); + const layoutContextDispatch = layoutDispatch(); + const intl = useIntl(); + return ( +
{ + layoutContextDispatch({ + type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN, + value: false, + }); + layoutContextDispatch({ + type: ACTIONS.SET_ID_CHAT_OPEN, + value: '', + }); + layoutContextDispatch({ + type: ACTIONS.SET_SIDEBAR_CONTENT_PANEL, + value: PANELS.NONE, + }); + }, + }} + rightButtonProps={{ + accessKey: CLOSE_CHAT_AK, + 'aria-label': intl.formatMessage(intlMessages.closeChatLabel, { 0: title }), + 'data-test': 'closePrivateChat', + icon: 'close', + label: intl.formatMessage(intlMessages.closeChatLabel, { 0: title }), + onClick: () => { + closePrivateChat(chatId); + layoutContextDispatch({ + type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN, + value: false, + }); + layoutContextDispatch({ + type: ACTIONS.SET_ID_CHAT_OPEN, + value: '', + }); + layoutContextDispatch({ + type: ACTIONS.SET_SIDEBAR_CONTENT_PANEL, + value: PANELS.NONE, + }); + }, + }} + customRightButton={isPublicChat? : null} + /> + ); +}; + +const isChatResponse = (data: unknown): data is GetChatDataResponse => { + return (data as GetChatDataResponse).chat !== undefined; +}; + +const ChatHeaderContainer: React.FC = () => { + const intl = useIntl(); + const idChatOpen = layoutSelect((i: Layout) => i.idChatOpen); + const { + data: chatData, + loading: chatDataLoading, + error: chatDataError, + } = useQuery(GET_CHAT_DATA, { + variables: { chatId: idChatOpen }, + }); + + if (chatDataLoading) return null; + if (chatDataError) return (
Error: {JSON.stringify(chatDataError)}
); + if (!isChatResponse(chatData)) return (
Error: {JSON.stringify(chatData)}
); + const isPublicChat = chatData.chat[0]?.public; + const title = isPublicChat ? intl.formatMessage(intlMessages.titlePublic) + : intl.formatMessage(intlMessages.titlePrivate, { 0: chatData?.chat[0]?.participant?.name }); + return ( + + ); +}; + +export default ChatHeaderContainer; \ No newline at end of file diff --git a/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-header/queries.ts b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-header/queries.ts new file mode 100644 index 0000000000..ae0142073d --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-header/queries.ts @@ -0,0 +1,25 @@ +import { gql } from '@apollo/client'; + +type chatContent = { + chatId: string; + public: boolean; + participant: { + name: string; + }; +} + +export interface GetChatDataResponse { + chat: Array; +} + +export const GET_CHAT_DATA = gql` +query GetChatData($chatId: String!) { + chat(where: {chatId: {_eq: $chatId }}) { + chatId + public + participant { + name + } + } +} +`; \ No newline at end of file diff --git a/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-header/services.ts b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-header/services.ts new file mode 100644 index 0000000000..d51f326cef --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-header/services.ts @@ -0,0 +1,14 @@ +import Storage from '/imports/ui/services/storage/session'; +import { indexOf } from '/imports/utils/array-utils'; + +// old code +const CLOSED_CHAT_LIST_KEY = 'closedChatList'; +export const closePrivateChat = (chatId: string) => { + const currentClosedChats = (Storage.getItem(CLOSED_CHAT_LIST_KEY) || []) as string[]; + + if (indexOf(currentClosedChats, chatId) < 0) { + currentClosedChats.push(chatId); + + Storage.setItem(CLOSED_CHAT_LIST_KEY, currentClosedChats); + } +}; \ No newline at end of file diff --git a/bigbluebutton-html5/imports/ui/components/chat/component.jsx b/bigbluebutton-html5/imports/ui/components/chat/component.jsx index 7d76cca972..f946fb5e70 100755 --- a/bigbluebutton-html5/imports/ui/components/chat/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/chat/component.jsx @@ -16,6 +16,7 @@ import browserInfo from '/imports/utils/browserInfo'; import Header from '/imports/ui/components/common/control-header/component'; import { CLOSE_PRIVATE_CHAT_MUTATION } from '../user-list/user-list-content/user-messages/chat-list/queries'; import { useMutation, gql } from '@apollo/client'; +import ChatHeader from './chat-graphql/chat-header/component'; const CHAT_CONFIG = Meteor.settings.public.chat; const PUBLIC_CHAT_ID = CHAT_CONFIG.public_id; @@ -81,57 +82,7 @@ const Chat = (props) => { isChrome={isChrome} data-test={isPublicChat ? 'publicChat' : 'privateChat'} > -
{ - layoutContextDispatch({ - type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN, - value: false, - }); - layoutContextDispatch({ - type: ACTIONS.SET_ID_CHAT_OPEN, - value: '', - }); - layoutContextDispatch({ - type: ACTIONS.SET_SIDEBAR_CONTENT_PANEL, - value: PANELS.NONE, - }); - }, - }} - rightButtonProps={{ - accessKey: CLOSE_CHAT_AK, - 'aria-label': intl.formatMessage(intlMessages.closeChatLabel, { 0: title }), - 'data-test': "closePrivateChat", - icon: "close", - label: intl.formatMessage(intlMessages.closeChatLabel, { 0: title }), - onClick: () => { - handleClosePrivateChat(); - layoutContextDispatch({ - type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN, - value: false, - }); - layoutContextDispatch({ - type: ACTIONS.SET_ID_CHAT_OPEN, - value: '', - }); - layoutContextDispatch({ - type: ACTIONS.SET_SIDEBAR_CONTENT_PANEL, - value: PANELS.NONE, - }); - }, - }} - customRightButton={isPublicChat && ( - - )} - /> +