diff --git a/lang/main.json b/lang/main.json index 06fc396426ca..6d8a3d1bd252 100644 --- a/lang/main.json +++ b/lang/main.json @@ -128,6 +128,7 @@ "privateNotice": "Private message to {{recipient}}", "sendButton": "Send", "smileysPanel": "Emoji panel", + "systemDisplayName": "System", "tabs": { "chat": "Chat", "polls": "Polls" diff --git a/react/features/chat/components/native/ChatMessage.tsx b/react/features/chat/components/native/ChatMessage.tsx index 595647a6977e..952b2944f0d7 100644 --- a/react/features/chat/components/native/ChatMessage.tsx +++ b/react/features/chat/components/native/ChatMessage.tsx @@ -9,6 +9,7 @@ import Linkify from '../../../base/react/components/native/Linkify'; import { isGifMessage } from '../../../gifs/functions.native'; import { MESSAGE_TYPE_ERROR, MESSAGE_TYPE_LOCAL } from '../../constants'; import { + getCanReplyToMessage, getFormattedTimestamp, getMessageText, getPrivateNoticeMessage, @@ -163,10 +164,10 @@ class ChatMessage extends Component { * @returns {React$Element<*> | null} */ _renderPrivateReplyButton() { - const { message, knocking } = this.props; - const { messageType, privateMessage, lobbyChat } = message; + const { message, canReply } = this.props; + const { lobbyChat } = message; - if (!(privateMessage || lobbyChat) || messageType === MESSAGE_TYPE_LOCAL || knocking) { + if (!canReply) { return null; } @@ -206,8 +207,9 @@ class ChatMessage extends Component { * @param {Object} state - The Redux state. * @returns {IProps} */ -function _mapStateToProps(state: IReduxState) { +function _mapStateToProps(state: IReduxState, { message }: IChatMessageProps) { return { + canReply: getCanReplyToMessage(state, message), knocking: state['features/lobby'].knocking }; } diff --git a/react/features/chat/components/web/ChatMessage.tsx b/react/features/chat/components/web/ChatMessage.tsx index e3d9e17de968..926902e423e1 100644 --- a/react/features/chat/components/web/ChatMessage.tsx +++ b/react/features/chat/components/web/ChatMessage.tsx @@ -7,8 +7,7 @@ import { IReduxState } from '../../../app/types'; import { translate } from '../../../base/i18n/functions'; import Message from '../../../base/react/components/web/Message'; import { withPixelLineHeight } from '../../../base/styles/functions.web'; -import { MESSAGE_TYPE_LOCAL } from '../../constants'; -import { getFormattedTimestamp, getMessageText, getPrivateNoticeMessage } from '../../functions'; +import { getCanReplyToMessage, getFormattedTimestamp, getMessageText, getPrivateNoticeMessage } from '../../functions'; import { IChatMessageProps } from '../../types'; import PrivateMessageButton from './PrivateMessageButton'; @@ -117,6 +116,7 @@ const useStyles = makeStyles()((theme: Theme) => { * @returns {JSX} */ const ChatMessage = ({ + canReply, knocking, message, showDisplayName, @@ -191,8 +191,7 @@ const ChatMessage = ({ {(message.privateMessage || (message.lobbyChat && !knocking)) && _renderPrivateNotice()} - {(message.privateMessage || (message.lobbyChat && !knocking)) - && message.messageType !== MESSAGE_TYPE_LOCAL + {canReply && (
@@ -214,10 +213,11 @@ const ChatMessage = ({ * @param {Object} state - The Redux state. * @returns {IProps} */ -function _mapStateToProps(state: IReduxState) { +function _mapStateToProps(state: IReduxState, { message }: IProps) { const { knocking } = state['features/lobby']; return { + canReply: getCanReplyToMessage(state, message), knocking }; } diff --git a/react/features/chat/constants.ts b/react/features/chat/constants.ts index 035c760ccf30..53b777644587 100644 --- a/react/features/chat/constants.ts +++ b/react/features/chat/constants.ts @@ -43,3 +43,8 @@ export const CHAT_TABS = { * Formatter string to display the message timestamp. */ export const TIMESTAMP_FORMAT = 'H:mm'; + +/** + * The namespace for system messages. + */ +export const MESSAGE_TYPE_SYSTEM = 'system_chat_message'; diff --git a/react/features/chat/functions.ts b/react/features/chat/functions.ts index 646768d0137d..a4f045cd43b5 100644 --- a/react/features/chat/functions.ts +++ b/react/features/chat/functions.ts @@ -7,6 +7,7 @@ import emojiAsciiAliases from 'react-emoji-render/data/asciiAliases'; import { IReduxState } from '../app/types'; import { getLocalizedDateFormatter } from '../base/i18n/dateUtil'; import i18next from '../base/i18n/i18next'; +import { getParticipantById } from '../base/participants/functions'; import { escapeRegexp } from '../base/util/helpers'; import { MESSAGE_TYPE_ERROR, MESSAGE_TYPE_LOCAL, TIMESTAMP_FORMAT } from './constants'; @@ -161,6 +162,23 @@ export function getMessageText(message: IMessage) { : message.message; } + +/** + * Returns whether a message can be replied to. + * + * @param {IReduxState} state - The redux state. + * @param {IMessage} message - The message to be checked. + * @returns {boolean} + */ +export function getCanReplyToMessage(state: IReduxState, message: IMessage) { + const { knocking } = state['features/lobby']; + const participant = getParticipantById(state, message.id); + + return Boolean(participant) + && (message.privateMessage || (message.lobbyChat && !knocking)) + && message.messageType !== MESSAGE_TYPE_LOCAL; +} + /** * Returns the message that is displayed as a notice for private messages. * diff --git a/react/features/chat/middleware.ts b/react/features/chat/middleware.ts index aa307a50ec29..c1ed2acc5d32 100644 --- a/react/features/chat/middleware.ts +++ b/react/features/chat/middleware.ts @@ -2,7 +2,11 @@ import { AnyAction } from 'redux'; import { IReduxState, IStore } from '../app/types'; import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from '../base/app/actionTypes'; -import { CONFERENCE_JOINED, ENDPOINT_MESSAGE_RECEIVED } from '../base/conference/actionTypes'; +import { + CONFERENCE_JOINED, + ENDPOINT_MESSAGE_RECEIVED, + NON_PARTICIPANT_MESSAGE_RECEIVED +} from '../base/conference/actionTypes'; import { getCurrentConference } from '../base/conference/functions'; import { IJitsiConference } from '../base/conference/reducer'; import { openDialog } from '../base/dialog/actions'; @@ -40,7 +44,8 @@ import { LOBBY_CHAT_MESSAGE, MESSAGE_TYPE_ERROR, MESSAGE_TYPE_LOCAL, - MESSAGE_TYPE_REMOTE + MESSAGE_TYPE_REMOTE, + MESSAGE_TYPE_SYSTEM } from './constants'; import { getUnreadCount } from './functions'; import { INCOMING_MSG_SOUND_FILE } from './sounds'; @@ -131,6 +136,23 @@ MiddlewareRegistry.register(store => next => action => { break; } + case NON_PARTICIPANT_MESSAGE_RECEIVED: { + const { id, json: data } = action; + + if (data?.type === MESSAGE_TYPE_SYSTEM && data.message) { + _handleReceivedMessage(store, { + displayName: data.displayName ?? i18next.t('chat.systemDisplayName'), + id, + lobbyChat: false, + message: data.message, + privateMessage: true, + timestamp: Date.now() + }); + } + + break; + } + case OPEN_CHAT: unreadCount = 0; diff --git a/react/features/chat/types.ts b/react/features/chat/types.ts index f9a4a9510b60..a58b289ab34d 100644 --- a/react/features/chat/types.ts +++ b/react/features/chat/types.ts @@ -39,10 +39,15 @@ export interface IChatProps extends WithTranslation { export interface IChatMessageProps extends WithTranslation { + /** + * Whether the message can be replied to. + */ + canReply?: boolean; + /** * Whether current participant is currently knocking in the lobby room. */ - knocking: boolean; + knocking?: boolean; /** * The representation of a chat message. diff --git a/resources/prosody-plugins/mod_system_chat_message.lua b/resources/prosody-plugins/mod_system_chat_message.lua new file mode 100644 index 000000000000..187c6d5a5112 --- /dev/null +++ b/resources/prosody-plugins/mod_system_chat_message.lua @@ -0,0 +1,124 @@ +-- Module which can be used as an http endpoint to send system chat messages to meeting participants. The provided token +--- in the request is verified whether it has the right to do so. This module should be loaded under the virtual host. +-- Copyright (C) 2024-present 8x8, Inc. + +-- curl https://{host}/send-system-message -d '{"message": "testmessage", "to": "{connection_jid}", "room": "{room_jid}"}' -H "content-type: application/json" -H "authorization: Bearer {token}" + +local util = module:require "util"; +local token_util = module:require "token/util".new(module); + +local async_handler_wrapper = util.async_handler_wrapper; +local room_jid_match_rewrite = util.room_jid_match_rewrite; +local starts_with = util.starts_with; +local get_room_from_jid = util.get_room_from_jid; + +local st = require "util.stanza"; +local json = require "cjson.safe"; + +local muc_domain_base = module:get_option_string("muc_mapper_domain_base"); +local asapKeyServer = module:get_option_string("prosody_password_public_key_repo_url", ""); + +if asapKeyServer then + -- init token util with our asap keyserver + token_util:set_asap_key_server(asapKeyServer) +end + +function verify_token(token) + if token == nil then + module:log("warn", "no token provided"); + return false; + end + + local session = {}; + session.auth_token = token; + local verified, reason, msg = token_util:process_and_verify_token(session); + if not verified then + module:log("warn", "not a valid token %s %s", tostring(reason), tostring(msg)); + return false; + end + return true; +end + +function handle_send_system_message (event) + local request = event.request; + + module:log("debug", "Request for sending a system message received: reqid %s", request.headers["request_id"]) + + -- verify payload + if request.headers.content_type ~= "application/json" + or (not request.body or #request.body == 0) then + module:log("error", "Wrong content type: %s or missing payload", request.headers.content_type); + return { status_code = 400; } + end + + local payload = json.decode(request.body); + + if not payload then + module:log("error", "Request body is missing"); + return { status_code = 400; } + end + + local displayName = payload["displayName"]; + local message = payload["message"]; + local to = payload["to"]; + local payload_room = payload["room"]; + + if not message or not to or not payload_room then + module:log("error", "One of [message, to, room] was not provided"); + return { status_code = 400; } + end + + local room_jid = room_jid_match_rewrite(payload_room); + local room = get_room_from_jid(room_jid); + + if not room then + module:log("error", "Room %s not found", room_jid); + return { status_code = 404; } + end + + -- verify access + local token = request.headers["authorization"] + if not token then + module:log("error", "Authorization header was not provided for conference %s", room_jid) + return { status_code = 401 }; + end + if starts_with(token, 'Bearer ') then + token = token:sub(8, #token) + else + module:log("error", "Authorization header is invalid") + return { status_code = 401 }; + end + + if not verify_token(token, room_jid) then + return { status_code = 401 }; + end + + local data = { + displayName = displayName, + type = "system_chat_message", + message = message, + }; + + local stanza = st.message({ + from = room.jid, + to = to + }) + :tag('json-message', { xmlns = 'http://jitsi.org/jitmeet' }) + :text(json.encode(data)) + :up(); + + room:route_stanza(stanza); + + return { status_code = 200 }; +end + +module:log("info", "Adding http handler for /send-system-chat-message on %s", module.host); +module:depends("http"); +module:provides("http", { + default_path = "/"; + route = { + ["POST send-system-chat-message"] = function(event) + return async_handler_wrapper(event, handle_send_system_message) + end; + }; +});