snapshot of https://github.com/jitsi/jitsi-meet.git @ fd1749dd28f4138aa80234fc7f311b19aaffa0e7 (2026-09-15)
Some checks failed
Prosody Plugin Tests / test (push) Failing after 10m11s
Close stale issues and PRs / stale (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-15 15:56:32 +05:00
commit 1d2bfcac0c
3159 changed files with 464524 additions and 0 deletions

View File

@@ -0,0 +1,267 @@
/**
* The type of the action which signals to add a new chat message.
*
* {
* type: ADD_MESSAGE,
* displayName: string
* hasRead: boolean,
* id: string,
* messageType: ChatMessageType,
* message: string,
* timestamp: string,
* }
*/
export const ADD_MESSAGE = 'ADD_MESSAGE';
/**
* The type of the action that adds a reaction to a chat message.
*
* {
* type: ADD_MESSAGE_REACTION,
* reaction: string,
* messageID: string,
* receiverID: string,
* }
*/
export const ADD_MESSAGE_REACTION = 'ADD_MESSAGE_REACTION';
/**
* The type of action which clears the chat search state.
*
* {
* type: CLEAR_CHAT_SEARCH
* }
*/
export const CLEAR_CHAT_SEARCH = 'CLEAR_CHAT_SEARCH';
/**
* The type of the action which signals to clear Redux.
*
* {
* type: CLEAR_CHAT_STATE
* }
*/
export const CLEAR_CHAT_STATE = 'CLEAR_CHAT_STATE';
/**
* The type of the action which signals the cancellation the chat panel.
*
* {
* type: CLOSE_CHAT
* }
*/
export const CLOSE_CHAT = 'CLOSE_CHAT';
/**
* The type of the action which signals to edit chat message.
*
* {
* type: EDIT_MESSAGE,
* message: Object
* }
*/
export const EDIT_MESSAGE = 'EDIT_MESSAGE';
/**
* The type of the action which signals to moderate a message
*
* {
* type: MODERATE_MESSAGE,
* messageId: string,
* reason: string
* }
*/
export const MODERATE_MESSAGE = 'MODERATE_MESSAGE';
/**
* The type of the action which signals to display the chat panel.
*
* {
* type: OPEN_CHAT
* }
*/
export const OPEN_CHAT = 'OPEN_CHAT';
/**
* The type of the action which signals a send a chat message to everyone in the
* conference.
*
* {
* type: SEND_MESSAGE,
* ignorePrivacy: boolean,
* message: string
* }
*/
export const SEND_MESSAGE = 'SEND_MESSAGE';
/**
* The type of the action which signals to moderates a chat message.
*
* {
* type: SEND_MESSAGE_MODERATION,
* message: IMessage,
* reason: string
* }
*/
export const SEND_MESSAGE_MODERATION = 'SEND_MESSAGE_MODERATION';
/*
* The type of the action which signals a request to retract
* (delete) a previously sent chat message.
*
* {
* type: SEND_MESSAGE_RETRACTION,
* message: IMessage
* }
*/
export const SEND_MESSAGE_RETRACTION = 'SEND_MESSAGE_RETRACTION';
/*
* The type of the action which signals sending an edit for an existing chat message.
*
* {
* type: SEND_MESSAGE_EDIT,
* messageId: string,
* message: string
* }
*/
export const SEND_MESSAGE_EDIT = 'SEND_MESSAGE_EDIT';
/**
* The type of the action which signals a reaction to a message.
*
* {
* type: SEND_REACTION,
* reaction: string,
* messageID: string,
* receiverID: string
* }
*/
export const SEND_REACTION = 'SEND_REACTION';
/**
* The type of action which sets the currently focused search match index.
*
* {
* type: SET_CHAT_SEARCH_MATCH_INDEX,
* index: number
* }
*/
export const SET_CHAT_SEARCH_MATCH_INDEX = 'SET_CHAT_SEARCH_MATCH_INDEX';
/**
* The type of action which sets the chat search query.
*
* {
* type: SET_CHAT_SEARCH_QUERY,
* query: string
* }
*/
export const SET_CHAT_SEARCH_QUERY = 'SET_CHAT_SEARCH_QUERY';
/**
* The type of action which signals the initiation of sending of as private message to the
* supplied recipient.
*
* {
* participant: Participant,
* type: SET_PRIVATE_MESSAGE_RECIPIENT
* }
*/
export const SET_PRIVATE_MESSAGE_RECIPIENT = 'SET_PRIVATE_MESSAGE_RECIPIENT';
/**
* The type of action which signals setting the focused tab.
*
* {
* type: SET_FOCUSED_TAB,
* tabId: string
* }
*/
export const SET_FOCUSED_TAB = 'SET_FOCUSED_TAB';
/**
* The type of action which sets the current recipient for lobby messages.
*
* {
* participant: Object,
* type: SET_LOBBY_CHAT_RECIPIENT
* }
*/
export const SET_LOBBY_CHAT_RECIPIENT = 'SET_LOBBY_CHAT_RECIPIENT';
/**
* The type of action sets the state of lobby messaging status.
*
* {
* type: SET_LOBBY_CHAT_ACTIVE_STATE
* payload: boolean
* }
*/
export const SET_LOBBY_CHAT_ACTIVE_STATE = 'SET_LOBBY_CHAT_ACTIVE_STATE';
/**
* The type of action removes the lobby messaging from participant.
*
* {
* type: REMOVE_LOBBY_CHAT_PARTICIPANT
* }
*/
export const REMOVE_LOBBY_CHAT_PARTICIPANT = 'REMOVE_LOBBY_CHAT_PARTICIPANT';
/**
* The type of action which signals to delete your own message after sending it (self deletion).
*
* {
* type: RETRACT_MESSAGE
* }
*/
export const RETRACT_MESSAGE = 'RETRACT_MESSAGE';
/**
* The type of action which signals to set the width of the chat panel.
*
* {
* type: SET_CHAT_WIDTH,
* width: number
* }
*/
export const SET_CHAT_WIDTH = 'SET_CHAT_WIDTH';
/**
* The type of action which sets the width for the chat panel (user resized).
* {
* type: SET_USER_CHAT_WIDTH,
* width: number
* }
*/
export const SET_USER_CHAT_WIDTH = 'SET_USER_CHAT_WIDTH';
/**
* The type of action which sets whether the user is resizing the chat panel or not.
* {
* type: SET_CHAT_IS_RESIZING,
* resizing: boolean
* }
*/
export const SET_CHAT_IS_RESIZING = 'SET_CHAT_IS_RESIZING';
/**
* The type of action sets the timestamp of the last private chat recipients list changed.
*
* {
* type: NOTIFY_PRIVATE_RECIPIENTS_CHANGED
* }
*/
export const NOTIFY_PRIVATE_RECIPIENTS_CHANGED = 'NOTIFY_PRIVATE_RECIPIENTS_CHANGED';
/**
* The type of action which sets whether the server handles message moderation
* and editing for this room.
*
* {
* type: SET_MESSAGE_MODERATION_SUPPORTED,
* supported: boolean
* }
*/
export const SET_MESSAGE_MODERATION_SUPPORTED = 'SET_MESSAGE_MODERATION_SUPPORTED';

View File

@@ -0,0 +1,535 @@
import { IStore } from '../app/types';
import { getCurrentConference } from '../base/conference/functions';
import { getLocalParticipant, getParticipantById } from '../base/participants/functions';
import { IParticipant } from '../base/participants/types';
import { LOBBY_CHAT_INITIALIZED } from '../lobby/constants';
import {
ADD_MESSAGE,
ADD_MESSAGE_REACTION,
CLEAR_CHAT_SEARCH,
CLEAR_CHAT_STATE,
CLOSE_CHAT,
EDIT_MESSAGE,
MODERATE_MESSAGE,
NOTIFY_PRIVATE_RECIPIENTS_CHANGED,
OPEN_CHAT,
REMOVE_LOBBY_CHAT_PARTICIPANT,
RETRACT_MESSAGE,
SEND_MESSAGE,
SEND_MESSAGE_EDIT,
SEND_MESSAGE_MODERATION,
SEND_MESSAGE_RETRACTION,
SEND_REACTION,
SET_CHAT_SEARCH_MATCH_INDEX,
SET_CHAT_SEARCH_QUERY,
SET_FOCUSED_TAB,
SET_LOBBY_CHAT_ACTIVE_STATE,
SET_LOBBY_CHAT_RECIPIENT,
SET_MESSAGE_MODERATION_SUPPORTED,
SET_PRIVATE_MESSAGE_RECIPIENT
} from './actionTypes';
import { ChatTabs } from './constants';
import { IMessage } from './types';
/**
* Adds a chat message to the collection of messages.
*
* @param {Object} messageDetails - The chat message to save.
* @param {string} messageDetails.displayName - The displayName of the
* participant that authored the message.
* @param {boolean} messageDetails.hasRead - Whether or not to immediately mark
* the message as read.
* @param {string} messageDetails.message - The received message to display.
* @param {string} messageDetails.messageType - The kind of message, such as
* "error" or "local" or "remote".
* @param {string} messageDetails.timestamp - A timestamp to display for when
* the message was received.
* @param {string} messageDetails.isReaction - Whether or not the
* message is a reaction message.
* @returns {{
* type: ADD_MESSAGE,
* displayName: string,
* hasRead: boolean,
* message: string,
* messageType: ChatMessageType,
* timestamp: string,
* isReaction: boolean
* }}
*/
export function addMessage(messageDetails: Object) {
return {
type: ADD_MESSAGE,
...messageDetails
};
}
/**
* Adds a reaction to a chat message.
*
* @param {Object} reactionDetails - The reaction to add.
* @param {string} reactionDetails.participantId - The ID of the message to react to.
* @param {string} reactionDetails.reactionList - The reaction to add.
* @param {string} reactionDetails.messageId - The receiver ID of the reaction.
* @returns {{
* type: ADD_MESSAGE_REACTION,
* participantId: string,
* reactionList: string[],
* messageId: string
* }}
*/
export function addMessageReaction(reactionDetails: Object) {
return {
type: ADD_MESSAGE_REACTION,
...reactionDetails
};
}
/**
* Edits an existing chat message.
*
* @param {Object} message - The chat message to edit/override. The messages will be matched from the state
* comparing the messageId.
* @returns {{
* type: EDIT_MESSAGE,
* message: Object
* }}
*/
export function editMessage(message: Object) {
return {
type: EDIT_MESSAGE,
message
};
}
/**
* Clears the chat search query and match index.
*
* @returns {{
* type: CLEAR_CHAT_SEARCH
* }}
*/
export function clearChatSearch() {
return {
type: CLEAR_CHAT_SEARCH
};
}
/**
* Clears the chat features state from Redux.
*
* @returns {{
* type: CLEAR_CHAT_STATE
* }}
*/
export function clearChatState() {
return {
type: CLEAR_CHAT_STATE
};
}
/**
* Action to signal the closing of the chat dialog.
*
* @returns {{
* type: CLOSE_CHAT
* }}
*/
export function closeChat() {
return {
type: CLOSE_CHAT
};
}
/**
* Sends a chat message to everyone in the conference.
*
* @param {string} message - The chat message to send out.
* @param {boolean} ignorePrivacy - True if the privacy notification should be ignored.
* @returns {{
* type: SEND_MESSAGE,
* ignorePrivacy: boolean,
* message: string
* }}
*/
export function sendMessage(message: string, ignorePrivacy = false) {
return {
type: SEND_MESSAGE,
ignorePrivacy,
message
};
}
/**
* Requests retraction (delete) of a previously sent message.
*
* @param {IMessage} message - The message to retract.
* @returns {Object}
*/
export function sendMessageRetraction(message: IMessage) {
return {
type: SEND_MESSAGE_RETRACTION,
message
};
}
/**
* Sends a reaction to a message.
*
* @param {string} reaction - The reaction to send.
* @param {string} messageId - The message ID to react to.
* @param {string} receiverId - The receiver ID of the reaction.
* @returns {Function}
*/
export function sendReaction(reaction: string, messageId: string, receiverId?: string) {
return {
type: SEND_REACTION,
reaction,
messageId,
receiverId
};
}
/**
* Sends an edit for an existing chat message to everyone in the conference.
*
* @param {string} messageId - The ID of the message being edited.
* @param {string} message - The updated chat message.
* @returns {{
* type: SEND_MESSAGE_EDIT,
* messageId: string,
* message: string
* }}
*/
export function sendMessageEdit(messageId: string, message: string) {
return {
type: SEND_MESSAGE_EDIT,
messageId,
message
};
}
/*
* Sets the chat search query and resets the match index back to the first result.
*
* @param {string} query - The search text.
* @returns {{
* type: SET_CHAT_SEARCH_QUERY,
* query: string
* }}
*/
export function setChatSearchQuery(query: string) {
return {
type: SET_CHAT_SEARCH_QUERY,
query
};
}
/**
* Sets which search match is currently focused (for prev/next navigation).
*
* @param {number} index - The index into the matches array.
* @returns {{
* type: SET_CHAT_SEARCH_MATCH_INDEX,
* index: number
* }}
*/
export function setChatSearchMatchIndex(index: number) {
return {
type: SET_CHAT_SEARCH_MATCH_INDEX,
index
};
}
/**
* Initiates the sending of a private message to the supplied participant.
*
* @param {IParticipant} participant - The participant to set the recipient to.
* @returns {{
* participant: IParticipant,
* type: SET_PRIVATE_MESSAGE_RECIPIENT
* }}
*/
export function setPrivateMessageRecipient(participant?: Object) {
return {
participant,
type: SET_PRIVATE_MESSAGE_RECIPIENT
};
}
/**
* Initiates the sending of a private message to the supplied participantId.
*
* @param {string} participantId - The participant id to set the recipient to.
* @returns {{
* participant: IParticipant,
* type: SET_PRIVATE_MESSAGE_RECIPIENT
* }}
*/
export function setPrivateMessageRecipientById(participantId: string) {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const participant = getParticipantById(getState(), participantId);
if (participant) {
dispatch(setPrivateMessageRecipient(participant));
}
};
}
/**
* Marks a message as retracted.
*
* @param {string} messageId - The retracted message id.
* @param {string} retractedBy - The participant ID of the user who retracted the message.
* @returns {Object}
*/
export function retractMessage(messageId: string, retractedBy: string) {
return {
type: RETRACT_MESSAGE,
messageId,
retractedBy
};
}
/**
* Set the value of the currently focused tab.
*
* @param {string} tabId - The id of the currently focused tab.
* @returns {{
* type: SET_FOCUSED_TAB,
* tabId: string
* }}
*/
export function setFocusedTab(tabId: ChatTabs) {
return {
type: SET_FOCUSED_TAB,
tabId
};
}
/**
* Opens the chat panel with CC tab active.
*
* @returns {Object} The redux action.
*/
export function openCCPanel() {
return async (dispatch: IStore['dispatch']) => {
dispatch(setFocusedTab(ChatTabs.CLOSED_CAPTIONS));
dispatch({
type: OPEN_CHAT
});
};
}
/**
* Opens the chat panel with polls tab active.
*
* @returns {Object} The redux action.
*/
export function openPollsPanel() {
return async (dispatch: IStore['dispatch']) => {
dispatch(setFocusedTab(ChatTabs.POLLS));
dispatch({
type: OPEN_CHAT
});
};
}
/**
* Opens the chat panel with file sharing tab active.
*
* @returns {Object} The redux action.
*/
export function openFileSharingPanel() {
return async (dispatch: IStore['dispatch']) => {
dispatch(setFocusedTab(ChatTabs.FILE_SHARING));
dispatch({
type: OPEN_CHAT
});
};
}
/**
* Initiates the sending of messages between a moderator and a lobby attendee.
*
* @param {Object} lobbyChatInitializedInfo - The information about the attendee and the moderator
* that is going to chat.
*
* @returns {Function}
*/
export function onLobbyChatInitialized(lobbyChatInitializedInfo: { attendee: IParticipant; moderator: IParticipant; }) {
return async (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const state = getState();
const conference = getCurrentConference(state);
const lobbyLocalId = conference?.myLobbyUserId();
if (!lobbyLocalId) {
return;
}
if (lobbyChatInitializedInfo.moderator.id === lobbyLocalId) {
dispatch({
type: SET_LOBBY_CHAT_RECIPIENT,
participant: lobbyChatInitializedInfo.attendee,
open: true
});
}
if (lobbyChatInitializedInfo.attendee.id === lobbyLocalId) {
return dispatch({
type: SET_LOBBY_CHAT_RECIPIENT,
participant: lobbyChatInitializedInfo.moderator,
open: false
});
}
};
}
/**
* Sets the lobby room's chat active state.
*
* @param {boolean} value - The active state.
*
* @returns {Object}
*/
export function setLobbyChatActiveState(value: boolean) {
return {
type: SET_LOBBY_CHAT_ACTIVE_STATE,
payload: value
};
}
/**
* Notifies the private chat recipients list changed.
*
* @returns {Object}
*/
export function notifyPrivateRecipientsChanged() {
return (dispatch: IStore['dispatch']) => {
const timestamp = Date.now();
return dispatch({
type: NOTIFY_PRIVATE_RECIPIENTS_CHANGED,
payload: timestamp
});
};
}
/**
* Removes lobby type messages.
*
* @param {boolean} removeLobbyChatMessages - Should remove messages from chat (works only for accepted users).
* If not specified, it will delete all lobby messages.
*
* @returns {Object}
*/
export function removeLobbyChatParticipant(removeLobbyChatMessages?: boolean) {
return {
type: REMOVE_LOBBY_CHAT_PARTICIPANT,
removeLobbyChatMessages
};
}
/**
* Handles initial setup of lobby message between
* Moderator and participant.
*
* @param {string} participantId - The participant id.
*
* @returns {Object}
*/
export function handleLobbyChatInitialized(participantId: string) {
return async (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
if (!participantId) {
return;
}
const state = getState();
const conference = state['features/base/conference'].conference;
const { knockingParticipants } = state['features/lobby'];
const { lobbyMessageRecipient } = state['features/chat'];
const me = getLocalParticipant(state);
const lobbyLocalId = conference?.myLobbyUserId();
if (lobbyMessageRecipient && lobbyMessageRecipient.id === participantId) {
return dispatch(setLobbyChatActiveState(true));
}
const attendee = knockingParticipants.find(p => p.id === participantId);
if (attendee && attendee.chattingWithModerator === lobbyLocalId) {
return dispatch({
type: SET_LOBBY_CHAT_RECIPIENT,
participant: attendee,
open: true
});
}
if (!attendee) {
return;
}
const payload = { type: LOBBY_CHAT_INITIALIZED,
moderator: {
...me,
name: 'Moderator',
id: lobbyLocalId
},
attendee };
// notify attendee privately.
conference?.sendLobbyMessage(payload, attendee.id);
// notify other moderators.
return conference?.sendLobbyMessage(payload);
};
}
/**
* Requests moderation of a chat message.
*
* The room applies the moderation and echoes it back to everybody, including the
* sender, so there is nothing to update locally here.
*
* @param {IMessage} message - The message to moderate.
* @param {string} reason - Reason to delete message.
* @returns {Object}
*/
export function sendMessageModeration(message: IMessage, reason?: string) {
return {
type: SEND_MESSAGE_MODERATION,
message,
reason
};
}
/**
* Marks a message as moderated.
*
* @param {string} messageId - The moderated message id.
* @param {string} reason - Optional moderation reason.
* @returns {Object}
*/
export function moderateMessage(messageId: string, reason?: string) {
return {
type: MODERATE_MESSAGE,
messageId,
reason
};
}
/**
* Sets whether the room handles message moderation and editing server side.
*
* @param {boolean} supported - Whether the room applies message moderation.
* @returns {Object}
*/
export function setMessageModerationSupported(supported: boolean) {
return {
type: SET_MESSAGE_MODERATION_SUPPORTED,
supported
};
}

View File

@@ -0,0 +1,34 @@
import { IStore } from '../app/types';
import { IParticipant } from '../base/participants/types';
import { navigate } from '../mobile/navigation/components/conference/ConferenceNavigationContainerRef';
import { screen } from '../mobile/navigation/routes';
import { OPEN_CHAT } from './actionTypes';
import { setFocusedTab } from './actions.any';
import { ChatTabs } from './constants';
export * from './actions.any';
/**
* Displays the chat panel with the CHAT tab active.
*
* @param {Object} participant - The recipient for the private chat.
* @param {boolean} disablePolls - Checks if polls are disabled.
*
* @returns {Function}
*/
export function openChat(participant?: IParticipant | undefined | Object, disablePolls?: boolean) {
return (dispatch: IStore['dispatch']) => {
if (disablePolls) {
navigate(screen.conference.chat);
} else {
navigate(screen.conference.chatTabs.main);
}
dispatch(setFocusedTab(ChatTabs.CHAT));
dispatch({
participant,
type: OPEN_CHAT
});
};
}

View File

@@ -0,0 +1,99 @@
// @ts-expect-error
import VideoLayout from '../../../modules/UI/videolayout/VideoLayout';
import { IStore } from '../app/types';
import {
OPEN_CHAT,
SET_CHAT_IS_RESIZING,
SET_CHAT_WIDTH,
SET_USER_CHAT_WIDTH
} from './actionTypes';
import { closeChat, setFocusedTab } from './actions.any';
import { ChatTabs } from './constants';
export * from './actions.any';
/**
* Displays the chat panel with the CHAT tab active.
*
* @param {Object} participant - The recipient for the private chat.
* @param {Object} _disablePolls - Used on native.
* @returns {{
* participant: Participant,
* type: OPEN_CHAT
* }}
*/
export function openChat(participant?: Object, _disablePolls?: boolean) {
return function(dispatch: IStore['dispatch']) {
dispatch(setFocusedTab(ChatTabs.CHAT));
dispatch({
participant,
type: OPEN_CHAT
});
};
}
/**
* Toggles display of the chat panel.
*
* @returns {Function}
*/
export function toggleChat() {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const isOpen = getState()['features/chat'].isOpen;
if (isOpen) {
dispatch(closeChat());
} else {
dispatch(openChat());
}
// Recompute the large video size whenever we toggle the chat, as it takes chat state into account.
VideoLayout.onResize();
};
}
/**
* Sets the chat panel's width.
*
* @param {number} width - The new width of the chat panel.
* @returns {{
* type: SET_CHAT_WIDTH,
* width: number
* }}
*/
export function setChatWidth(width: number) {
return {
type: SET_CHAT_WIDTH,
width
};
}
/**
* Sets the chat panel's width and the user preferred width.
*
* @param {number} width - The new width of the chat panel.
* @returns {{
* type: SET_USER_CHAT_WIDTH,
* width: number
* }}
*/
export function setUserChatWidth(width: number) {
return {
type: SET_USER_CHAT_WIDTH,
width
};
}
/**
* Sets whether the user is resizing the chat panel or not.
*
* @param {boolean} resizing - Whether the user is resizing or not.
* @returns {Object}
*/
export function setChatIsResizing(resizing: boolean) {
return {
type: SET_CHAT_IS_RESIZING,
resizing
};
}

View File

@@ -0,0 +1,131 @@
import { PureComponent } from 'react';
import { WithTranslation } from 'react-i18next';
import { IReduxState, IStore } from '../../app/types';
import { getParticipantById } from '../../base/participants/functions';
import { IParticipant } from '../../base/participants/types';
import { IVisitorChatParticipant } from '../../visitors/types';
import { sendMessage, setPrivateMessageRecipient } from '../actions';
interface IProps extends WithTranslation {
/**
* Prop to be invoked on sending the message.
*/
_onSendMessage: Function;
/**
* Prop to be invoked when the user wants to set a private recipient.
*/
_onSetMessageRecipient: Function;
/**
* The participant retrieved from Redux by the participantID prop.
*/
_participant?: IParticipant;
/**
* The display name of the visitor (if applicable).
*/
displayName?: string;
/**
* Whether the message is from a visitor.
*/
isFromVisitor?: boolean;
/**
* The message that is about to be sent.
*/
message: Object;
/**
* The ID of the participant that we think the message may be intended to.
*/
participantID: string;
}
/**
* Abstract class for the dialog displayed to avoid mis-sending private messages.
*/
export class AbstractChatPrivacyDialog extends PureComponent<IProps> {
/**
* Instantiates a new instance.
*
* @inheritdoc
*/
constructor(props: IProps) {
super(props);
this._onSendGroupMessage = this._onSendGroupMessage.bind(this);
this._onSendPrivateMessage = this._onSendPrivateMessage.bind(this);
}
/**
* Callback to be invoked for cancel action (user wants to send a group message).
*
* @returns {boolean}
*/
_onSendGroupMessage() {
this.props._onSendMessage(this.props.message);
return true;
}
/**
* Callback to be invoked for submit action (user wants to send a private message).
*
* @returns {void}
*/
_onSendPrivateMessage() {
const { message, _onSendMessage, _onSetMessageRecipient, _participant, isFromVisitor, displayName, participantID } = this.props;
if (isFromVisitor) {
// For visitors, create a participant object since they don't exist in the main participant list
const visitorParticipant = {
id: participantID,
name: displayName,
isVisitor: true
};
_onSetMessageRecipient(visitorParticipant);
} else {
_onSetMessageRecipient(_participant);
}
_onSendMessage(message);
return true;
}
}
/**
* Maps part of the props of this component to Redux actions.
*
* @param {Function} dispatch - The Redux dispatch function.
* @returns {IProps}
*/
export function _mapDispatchToProps(dispatch: IStore['dispatch']) {
return {
_onSendMessage: (message: string) => {
dispatch(sendMessage(message, true));
},
_onSetMessageRecipient: (participant: IParticipant | IVisitorChatParticipant) => {
dispatch(setPrivateMessageRecipient(participant));
}
};
}
/**
* Maps part of the Redux store to the props of this component.
*
* @param {IReduxState} state - The Redux state.
* @param {IProps} ownProps - The own props of the component.
* @returns {IProps}
*/
export function _mapStateToProps(state: IReduxState, ownProps: IProps) {
return {
_participant: ownProps.isFromVisitor ? undefined : getParticipantById(state, ownProps.participantID)
};
}

View File

@@ -0,0 +1,116 @@
import React, { ComponentType, useCallback, useEffect, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { IReduxState } from '../../app/types';
import { openDialog } from '../../base/dialog/actions';
import { MEET_FEATURES } from '../../base/jwt/constants';
import { IMessageGroup, groupMessagesBySender } from '../../base/util/messageGrouping';
import { maybeShowPremiumFeatureDialog } from '../../jaas/actions';
import { RecordingTranscriptionDialog } from '../../recording/components/Recording';
import { setRequestingSubtitles } from '../../subtitles/actions.any';
import { canStartSubtitles } from '../../subtitles/functions.any';
import { ISubtitle } from '../../subtitles/types';
import { isTranscribing } from '../../transcribing/functions';
export type AbstractProps = {
canStartSubtitles: boolean;
filteredSubtitles: ISubtitle[];
groupedSubtitles: IMessageGroup<ISubtitle>[];
isButtonPressed: boolean;
isTranscribing: boolean;
startClosedCaptions: () => void;
};
const AbstractClosedCaptions = (Component: ComponentType<AbstractProps>) => () => {
const dispatch = useDispatch();
const subtitles = useSelector((state: IReduxState) => state['features/subtitles'].subtitlesHistory);
const language = useSelector((state: IReduxState) => state['features/subtitles']._language);
const selectedLanguage = language?.replace('translation-languages:', '');
const _isTranscribing = useSelector(isTranscribing);
const _canStartSubtitles = useSelector(canStartSubtitles);
const [ isButtonPressed, setButtonPressed ] = useState(false);
const subtitlesError = useSelector((state: IReduxState) => state['features/subtitles']._hasError);
const isAsyncTranscriptionEnabled = useSelector((state: IReduxState) =>
state['features/base/conference'].conference?.getMetadataHandler()?.getMetadata()?.asyncTranscription);
const filteredSubtitles = useMemo(() => {
// First, create a map of transcription messages by message ID
const transcriptionMessages = new Map(
subtitles
.filter(s => s.isTranscription)
.map(s => [ s.id, s ])
);
if (!selectedLanguage) {
// When no language is selected, show all original transcriptions
return Array.from(transcriptionMessages.values());
}
// Then, create a map of translation messages by message ID
const translationMessages = new Map(
subtitles
.filter(s => !s.isTranscription && s.language === selectedLanguage)
.map(s => [ s.id, s ])
);
// When a language is selected, for each transcription message:
// 1. Use its translation if available
// 2. Fall back to the original transcription if no translation exists
return Array.from(transcriptionMessages.values())
.filter((m: ISubtitle) => !m.interim)
.map(m => translationMessages.get(m.id) ?? m);
}, [ subtitles, selectedLanguage ]);
const groupedSubtitles = useMemo(() =>
groupMessagesBySender(filteredSubtitles), [ filteredSubtitles ]);
const startClosedCaptions = useCallback(() => {
if (dispatch(maybeShowPremiumFeatureDialog(MEET_FEATURES.RECORDING))) {
return;
}
if (isAsyncTranscriptionEnabled) {
dispatch(openDialog('RecordingTranscriptionDialog', RecordingTranscriptionDialog, {
recordAudioAndVideo: false
}));
} else {
if (isButtonPressed) {
return;
}
dispatch(setRequestingSubtitles(true, false, null));
setButtonPressed(true);
}
}, [ isAsyncTranscriptionEnabled, dispatch, isButtonPressed, openDialog, setButtonPressed ]);
useEffect(() => {
if (subtitlesError && isButtonPressed && !isAsyncTranscriptionEnabled) {
setButtonPressed(false);
}
}, [ subtitlesError, isButtonPressed, isAsyncTranscriptionEnabled ]);
useEffect(() => {
if (!_isTranscribing && isButtonPressed && !isAsyncTranscriptionEnabled) {
setButtonPressed(false);
}
}, [ _isTranscribing, isButtonPressed, isAsyncTranscriptionEnabled ]);
useEffect(() => {
if (isButtonPressed && !isAsyncTranscriptionEnabled) {
setButtonPressed(false);
}
}, [ isButtonPressed, isAsyncTranscriptionEnabled ]);
return (
<Component
canStartSubtitles = { _canStartSubtitles }
filteredSubtitles = { filteredSubtitles }
groupedSubtitles = { groupedSubtitles }
isButtonPressed = { isButtonPressed }
isTranscribing = { _isTranscribing }
startClosedCaptions = { startClosedCaptions } />
);
};
export default AbstractClosedCaptions;

View File

@@ -0,0 +1,65 @@
import { Component } from 'react';
import { ReactReduxContext } from 'react-redux';
import { IMessage } from '../types';
export interface IProps {
/**
* The messages array to render.
*/
messages: IMessage[];
}
/**
* Abstract component to display a list of chat messages, grouped by sender.
*
* @augments PureComponent
*/
export default class AbstractMessageContainer<P extends IProps, S> extends Component<P, S> {
static override contextType = ReactReduxContext;
declare context: React.ContextType<typeof ReactReduxContext>;
static defaultProps = {
messages: [] as IMessage[]
};
/**
* Iterates over all the messages and creates nested arrays which hold
* consecutive messages sent by the same participant.
*
* @private
* @returns {Array<Array<Object>>}
*/
_getMessagesGroupedBySender() {
const messagesCount = this.props.messages.length;
const groups: IMessage[][] = [];
let currentGrouping: IMessage[] = [];
let currentGroupParticipantId;
const { store } = this.context;
const state = store.getState();
const { disableReactionsInChat } = state['features/base/config'];
for (let i = 0; i < messagesCount; i++) {
const message = this.props.messages[i];
if (message.isReaction && disableReactionsInChat) {
continue;
}
if (message.participantId === currentGroupParticipantId) {
currentGrouping.push(message);
} else {
currentGrouping.length && groups.push(currentGrouping);
currentGrouping = [ message ];
currentGroupParticipantId = message.participantId;
}
}
currentGrouping.length && groups.push(currentGrouping);
return groups;
}
}

View File

@@ -0,0 +1,99 @@
import { PureComponent } from 'react';
import { WithTranslation } from 'react-i18next';
import { IReduxState, IStore } from '../../app/types';
import { getParticipantDisplayName, isLocalParticipantModerator } from '../../base/participants/functions';
import { getDisplayName } from '../../visitors/functions';
import { setLobbyChatActiveState, setPrivateMessageRecipient } from '../actions.any';
import { isVisitorChatParticipant } from '../functions';
export interface IProps extends WithTranslation {
/**
* Is lobby messaging active.
*/
_isLobbyChatActive: boolean;
/**
* Whether the private message recipient is a visitor.
*/
_isVisitor?: boolean;
/**
* The name of the lobby message recipient, if any.
*/
_lobbyMessageRecipient?: string;
/**
* Function to make the lobby message recipient inactive.
*/
_onHideLobbyChatRecipient: () => void;
/**
* Function to remove the recipient setting of the chat window.
*/
_onRemovePrivateMessageRecipient: () => void;
/**
* The name of the message recipient, if any.
*/
_privateMessageRecipient?: string;
/**
* Shows widget if it is necessary.
*/
_visible: boolean;
}
/**
* Abstract class for the {@code MessageRecipient} component.
*/
export default class AbstractMessageRecipient<P extends IProps> extends PureComponent<P> {
}
/**
* Maps part of the props of this component to Redux actions.
*
* @param {Function} dispatch - The Redux dispatch function.
* @returns {IProps}
*/
export function _mapDispatchToProps(dispatch: IStore['dispatch']) {
return {
_onRemovePrivateMessageRecipient: () => {
dispatch(setPrivateMessageRecipient());
},
_onHideLobbyChatRecipient: () => {
dispatch(setLobbyChatActiveState(false));
}
};
}
/**
* Maps part of the Redux store to the props of this component.
*
* @param {Object} state - The Redux state.
* @param {any} _ownProps - Components' own props.
* @returns {IProps}
*/
export function _mapStateToProps(state: IReduxState, _ownProps: any) {
const { privateMessageRecipient, lobbyMessageRecipient, isLobbyChatActive } = state['features/chat'];
let _privateMessageRecipient;
const _isVisitor = isVisitorChatParticipant(privateMessageRecipient);
if (privateMessageRecipient) {
_privateMessageRecipient = _isVisitor
? getDisplayName(state, privateMessageRecipient.name)
: getParticipantDisplayName(state, privateMessageRecipient.id);
}
return {
_privateMessageRecipient,
_isVisitor,
_isLobbyChatActive: isLobbyChatActive,
_lobbyMessageRecipient:
isLobbyChatActive && lobbyMessageRecipient ? lobbyMessageRecipient.name : undefined,
_visible: isLobbyChatActive ? isLocalParticipantModerator(state) : true
};
}

View File

@@ -0,0 +1,2 @@
// @ts-ignore
export { default as ChatPrivacyDialog } from './native/ChatPrivacyDialog';

View File

@@ -0,0 +1 @@
export { default as ChatPrivacyDialog } from './web/ChatPrivacyDialog';

View File

@@ -0,0 +1,158 @@
/* eslint-disable react/no-multi-comp */
import { Route, useIsFocused } from '@react-navigation/native';
import React, { Component, useEffect } from 'react';
import { connect, useSelector } from 'react-redux';
import { IReduxState } from '../../../app/types';
import { translate } from '../../../base/i18n/functions.native';
import JitsiScreen from '../../../base/modal/components/JitsiScreen';
import { StyleType } from '../../../base/styles/functions.native';
import { TabBarLabelCounter } from '../../../mobile/navigation/components/TabBarLabelCounter';
import { pollsStyles } from '../../../polls/components/native/styles';
import { clearChatSearch, closeChat, sendMessage } from '../../actions.native';
import { ChatTabs } from '../../constants';
import { IChatProps as AbstractProps } from '../../types';
import ChatInputBar from './ChatInputBar';
import ChatSearchBar from './ChatSearchBar';
import MessageContainer from './MessageContainer';
import MessageRecipient from './MessageRecipient';
interface IProps extends AbstractProps {
/**
* The number of unread messages.
*/
_unreadMessagesCount: number;
/**
* Default prop for navigating between screen components(React Navigation).
*/
navigation: any;
/**
* Default prop for navigating between screen components(React Navigation).
*/
route: Route<'', { privateMessageRecipient: { name: string; }; }>;
}
/**
* Implements a React native component that renders the chat window (modal) of
* the mobile client.
*/
class Chat extends Component<IProps> {
/**
* Initializes a new {@code AbstractChat} instance.
*
* @param {Props} props - The React {@code Component} props to initialize
* the new {@code AbstractChat} instance with.
*/
constructor(props: IProps) {
super(props);
// Bind event handlers so they are only bound once per instance.
this._onSendMessage = this._onSendMessage.bind(this);
this._renderFooter = this._renderFooter.bind(this);
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
*/
override render() {
const { _messages, route } = this.props;
const privateMessageRecipient = route?.params?.privateMessageRecipient;
return (
<JitsiScreen
disableForcedKeyboardDismiss = { true }
footerComponent = { this._renderFooter }
hasBottomTextInput = { true }
hasExtraHeaderHeight = { true }
style = { pollsStyles.pollPaneContainer as StyleType }>
<ChatSearchBar />
{/* @ts-ignore */}
<MessageContainer messages = { _messages } />
<MessageRecipient privateMessageRecipient = { privateMessageRecipient } />
</JitsiScreen>
);
}
/**
* Renders the footer component.
*
* @private
* @returns {React$Element<*>}
*/
_renderFooter() {
return <ChatInputBar onSend = { this._onSendMessage } />;
}
/**
* Sends a text message.
*
* @private
* @param {string} text - The text message to be sent.
* @returns {void}
* @type {Function}
*/
_onSendMessage(text: string) {
this.props.dispatch(sendMessage(text));
}
}
/**
* Maps (parts of) the redux state to {@link Chat} React {@code Component}
* props.
*
* @param {Object} state - The redux store/state.
* @param {any} _ownProps - Components' own props.
* @private
* @returns {{
* _messages: Array<Object>,
* _unreadMessagesCount: number
* }}
*/
function _mapStateToProps(state: IReduxState, _ownProps: any) {
const { messages, unreadMessagesCount } = state['features/chat'];
return {
_messages: messages,
_unreadMessagesCount: unreadMessagesCount
};
}
export default translate(connect(_mapStateToProps)((props: IProps) => {
const { _unreadMessagesCount, dispatch, navigation, t } = props;
const isChatTabFocused = useSelector((state: IReduxState) => state['features/chat'].focusedTab === ChatTabs.CHAT);
const isFocused = useIsFocused();
const activeUnreadMessagesNr = !isChatTabFocused && _unreadMessagesCount > 0;
useEffect(() => {
navigation?.setOptions({
tabBarLabel: () => (
<TabBarLabelCounter
activeUnreadNr = { activeUnreadMessagesNr }
isFocused = { isFocused }
label = { t('chat.tabs.chat') }
unreadCount = { _unreadMessagesCount } />
)
});
return () => {
if (isFocused) {
dispatch(closeChat());
dispatch(clearChatSearch());
}
};
}, [ isFocused, _unreadMessagesCount ]);
return (
<Chat { ...props } />
);
}));

View File

@@ -0,0 +1,78 @@
import { connect } from 'react-redux';
import { IReduxState } from '../../../app/types';
import { CHAT_ENABLED } from '../../../base/flags/constants';
import { getFeatureFlag } from '../../../base/flags/functions';
import { translate } from '../../../base/i18n/functions';
import { IconChatUnread, IconMessage } from '../../../base/icons/svg';
import AbstractButton, { IProps as AbstractButtonProps } from '../../../base/toolbox/components/AbstractButton';
import { arePollsDisabled } from '../../../conference/functions.any';
import { navigate } from '../../../mobile/navigation/components/conference/ConferenceNavigationContainerRef';
import { screen } from '../../../mobile/navigation/routes';
import { getUnreadPollCount } from '../../../polls/functions';
import { getUnreadCount, getUnreadFilesCount, isChatDisabled } from '../../functions';
interface IProps extends AbstractButtonProps {
/**
* True if the polls feature is disabled.
*/
_isPollsDisabled?: boolean;
/**
* The unread message count.
*/
_unreadMessageCount: number;
}
/**
* Implements an {@link AbstractButton} to open the chat screen on mobile.
*/
class ChatButton extends AbstractButton<IProps> {
override accessibilityLabel = 'toolbar.accessibilityLabel.chat';
override icon = IconMessage;
override label = 'toolbar.chat';
override toggledIcon = IconChatUnread;
/**
* Handles clicking / pressing the button, and opens the appropriate dialog.
*
* @private
* @returns {void}
*/
override _handleClick() {
this.props._isPollsDisabled
? navigate(screen.conference.chat)
: navigate(screen.conference.chatTabs.main);
}
/**
* Renders the button toggled when there are unread messages.
*
* @protected
* @returns {boolean}
*/
override _isToggled() {
return Boolean(this.props._unreadMessageCount);
}
}
/**
* Maps part of the redux state to the component's props.
*
* @param {Object} state - The Redux state.
* @param {Object} ownProps - The properties explicitly passed to the component instance.
* @returns {IProps}
*/
function _mapStateToProps(state: IReduxState, ownProps: any) {
const enabled = getFeatureFlag(state, CHAT_ENABLED, true) && !isChatDisabled(state);
const { visible = enabled } = ownProps;
return {
_isPollsDisabled: arePollsDisabled(state),
_unreadMessageCount: getUnreadCount(state) || getUnreadPollCount(state) || getUnreadFilesCount(state),
visible
};
}
export default translate(connect(_mapStateToProps)(ChatButton));

View File

@@ -0,0 +1,201 @@
import React, { Component } from 'react';
import { WithTranslation } from 'react-i18next';
import { Platform, TextStyle, View, ViewStyle } from 'react-native';
import { Text } from 'react-native-paper';
import { connect } from 'react-redux';
import { IReduxState } from '../../../app/types';
import { translate } from '../../../base/i18n/functions';
import { IconSend } from '../../../base/icons/svg';
import IconButton from '../../../base/ui/components/native/IconButton';
import Input from '../../../base/ui/components/native/Input';
import { BUTTON_TYPES } from '../../../base/ui/constants.native';
import { isSendGroupChatDisabled } from '../../functions';
import styles from './styles';
interface IProps extends WithTranslation {
/**
* Whether sending group chat messages is disabled.
*/
_isSendGroupChatDisabled: boolean;
/**
* The id of the message recipient, if any.
*/
_privateMessageRecipientId?: string;
/**
* Application's aspect ratio.
*/
aspectRatio: Symbol;
/**
* Callback to invoke on message send.
*/
onSend: Function;
}
interface IState {
/**
* Boolean to show if an extra padding needs to be added to the bar.
*/
addPadding: boolean;
/**
* The value of the input field.
*/
message: string;
/**
* Boolean to show or hide the send button.
*/
showSend: boolean;
}
/**
* Implements the chat input bar with text field and action(s).
*/
class ChatInputBar extends Component<IProps, IState> {
/**
* Instantiates a new instance of the component.
*
* @inheritdoc
*/
constructor(props: IProps) {
super(props);
this.state = {
addPadding: false,
message: '',
showSend: false
};
this._onChangeText = this._onChangeText.bind(this);
this._onFocused = this._onFocused.bind(this);
this._onSubmit = this._onSubmit.bind(this);
}
/**
* Implements {@code Component#render}.
*
* @inheritdoc
*/
override render() {
if (this.props._isSendGroupChatDisabled && !this.props._privateMessageRecipientId) {
return (
<View
id = 'no-messages-message'
style = { styles.disabledSendWrapper as ViewStyle }>
<Text style = { styles.emptyComponentText as TextStyle }>
{ this.props.t('chat.disabled') }
</Text>
</View>
);
}
return (
<View
id = 'chat-input'
style = { [
styles.inputBar,
this.state.addPadding ? styles.extraBarPadding : null
] as ViewStyle[] }>
<Input
blurOnSubmit = { false }
customStyles = {{ container: styles.customInputContainer }}
id = 'chat-input-messagebox'
multiline = { false }
onBlur = { this._onFocused(false) }
onChange = { this._onChangeText }
onFocus = { this._onFocused(true) }
onSubmitEditing = { this._onSubmit }
placeholder = { this.props.t('chat.fieldPlaceHolder') }
returnKeyType = 'send'
value = { this.state.message } />
<IconButton
disabled = { !this.state.message }
id = { this.props.t('chat.sendButton') }
onPress = { this._onSubmit }
src = { IconSend }
style = { styles.sendButton }
type = { BUTTON_TYPES.PRIMARY } />
</View>
);
}
/**
* Callback to handle the change of the value of the text field.
*
* @param {string} text - The current value of the field.
* @returns {void}
*/
_onChangeText(text: string) {
this.setState({
message: text,
showSend: Boolean(text)
});
}
/**
* Constructs a callback to be used to update the padding of the field if necessary.
*
* @param {boolean} focused - True of the field is focused.
* @returns {Function}
*/
_onFocused(focused: boolean) {
return () => {
Platform.OS === 'android' && this.setState({
addPadding: focused
});
};
}
/**
* Callback to handle the submit event of the text field.
*
* @returns {void}
*/
_onSubmit() {
const {
_isSendGroupChatDisabled,
_privateMessageRecipientId,
onSend
} = this.props;
if (_isSendGroupChatDisabled && !_privateMessageRecipientId) {
return;
}
const message = this.state.message.trim();
message && onSend(message);
this.setState({
message: '',
showSend: false
});
}
}
/**
* Maps part of the Redux state to the props of this component.
*
* @param {Object} state - The redux state.
* @private
* @returns {IProps}
*/
function _mapStateToProps(state: IReduxState) {
const { aspectRatio } = state['features/base/responsive-ui'];
const { privateMessageRecipient } = state['features/chat'];
const isGroupChatDisabled = isSendGroupChatDisabled(state);
return {
_isSendGroupChatDisabled: isGroupChatDisabled,
_privateMessageRecipientId: privateMessageRecipient?.id,
aspectRatio
};
}
export default translate(connect(_mapStateToProps)(ChatInputBar));

View File

@@ -0,0 +1,293 @@
import React, { Component } from 'react';
import { Text, View, ViewStyle } from 'react-native';
import { connect } from 'react-redux';
import { IReduxState } from '../../../app/types';
import Avatar from '../../../base/avatar/components/Avatar';
import { translate } from '../../../base/i18n/functions';
import Linkify from '../../../base/react/components/native/Linkify';
import { isGifEnabled, isGifMessage } from '../../../gifs/functions.native';
import { CHAR_LIMIT, MESSAGE_TYPE_ERROR, MESSAGE_TYPE_LOCAL } from '../../constants';
import {
getActiveChatSearchMatch,
getCanReplyToMessage,
getChatSearchQuery,
getDisplayNameSuffix,
getFormattedTimestamp,
getHighlightSegments,
getMessageText,
getPrivateNoticeMessage,
replaceNonUnicodeEmojis
} from '../../functions';
import { IChatMessageProps } from '../../types';
import GifMessage from './GifMessage';
import PrivateMessageButton from './PrivateMessageButton';
import styles from './styles';
/**
* Renders a single chat message.
*/
class ChatMessage extends Component<IChatMessageProps> {
/**
* Implements {@code Component#render}.
*
* @inheritdoc
*/
override render() {
const { gifEnabled, message, knocking, isActiveMatch } = this.props;
const localMessage = message.messageType === MESSAGE_TYPE_LOCAL;
const { privateMessage, lobbyChat } = message;
// Style arrays that need to be updated in various scenarios, such as
// error messages or others.
const detailsWrapperStyle: ViewStyle[] = [
styles.detailsWrapper as ViewStyle
];
const messageBubbleStyle: ViewStyle[] = [
styles.messageBubble as ViewStyle
];
if (localMessage) {
// This is a message sent by the local participant.
// The wrapper needs to be aligned to the right.
detailsWrapperStyle.push(styles.ownMessageDetailsWrapper as ViewStyle);
// The bubble needs some additional styling
messageBubbleStyle.push(styles.localMessageBubble);
} else if (message.messageType === MESSAGE_TYPE_ERROR) {
// This is a system message.
// The bubble needs some additional styling
messageBubbleStyle.push(styles.systemMessageBubble);
} else {
// This is a remote message sent by a remote participant.
// The bubble needs some additional styling
messageBubbleStyle.push(styles.remoteMessageBubble);
}
if (privateMessage) {
messageBubbleStyle.push(styles.privateMessageBubble);
}
if (lobbyChat && !knocking) {
messageBubbleStyle.push(styles.lobbyMessageBubble);
}
if (isActiveMatch) {
messageBubbleStyle.push(styles.activeMatchMessageBubble);
}
const messageText = message.isDeleted
? this.props.t(localMessage ? 'chat.deletedMessageByMe' : 'chat.deletedMessage')
: getMessageText(this.props.message);
return (
<View
id = { message.messageId }
style = { styles.messageWrapper as ViewStyle } >
{ this._renderAvatar() }
<View style = { detailsWrapperStyle }>
<View style = { messageBubbleStyle }>
<View style = { styles.textWrapper as ViewStyle } >
{ this._renderDisplayName() }
{ gifEnabled && isGifMessage(messageText)
? <GifMessage message = { messageText } />
: this._renderMessageTextComponent(messageText) }
{ this._renderPrivateNotice() }
</View>
{ !message.isDeleted && this._renderPrivateReplyButton() }
</View>
{ this._renderTimestamp() }
</View>
</View>
);
}
/**
* Renders the avatar of the sender.
*
* @returns {React.ReactElement<*>}
*/
_renderAvatar() {
const { message } = this.props;
return (
<View style = { styles.avatarWrapper }>
{ this.props.showAvatar && <Avatar
displayName = { message.displayName }
participantId = { message.participantId }
size = { styles.avatarWrapper.width } />
}
</View>
);
}
/**
* Renders the display name of the sender if necessary.
*
* @returns {React.ReactElement<*> | null}
*/
_renderDisplayName() {
const { message, showDisplayName } = this.props;
if (!showDisplayName) {
return null;
}
const { displayName } = message;
return (
<Text style = { styles.senderDisplayName }>
{ `${displayName}${getDisplayNameSuffix(message)}` }
</Text>
);
}
/**
* Renders the message text based on number of characters.
*
* @param {string} messageText - The message text.
* @returns {React.ReactElement<*>}
*/
_renderMessageTextComponent(messageText: string) {
const { highlightQuery, isActiveMatch } = this.props;
if (messageText.length >= CHAR_LIMIT) {
if (highlightQuery && !isActiveMatch) {
return (
<Text
selectable = { true }
style = { styles.chatMessage }>
{ this._renderHighlightedSegments(messageText, highlightQuery, isActiveMatch) }
</Text>
);
}
return (
<Text
selectable = { true }
style = { styles.chatMessage }>
{ messageText }
</Text>
);
}
const processedText = replaceNonUnicodeEmojis(messageText);
return (
<Linkify
linkStyle = { styles.chatLink }
style = { styles.chatMessage }>
{ highlightQuery && !isActiveMatch
? this._renderHighlightedSegments(processedText, highlightQuery, isActiveMatch)
: processedText }
</Linkify>
);
}
/**
* Splits text into highlight/non-highlight segments and renders them.
*
* @param {string} text - The text to split.
* @param {string} query - The active search query.
* @param {boolean} [isActiveMatch] - Whether this message is the focused match.
* @returns {Array<React.ReactNode>}
*/
_renderHighlightedSegments(text: string, query: string, isActiveMatch?: boolean) {
return getHighlightSegments(text, query).map((segment, index) => segment.match
? (
<Text
key = { index }
style = { isActiveMatch ? styles.chatMessageHighlightActive : styles.chatMessageHighlight }>
{ segment.text }
</Text>
)
: segment.text);
}
/**
* Renders the message privacy notice, if necessary.
*
* @returns {React.ReactElement<*> | null}
*/
_renderPrivateNotice() {
const { message, knocking } = this.props;
if (!(message.privateMessage || (message.lobbyChat && !knocking))) {
return null;
}
return (
<Text style = { message.lobbyChat ? styles.lobbyMsgNotice : styles.privateNotice }>
{ getPrivateNoticeMessage(this.props.message) }
</Text>
);
}
/**
* Renders the private reply button, if necessary.
*
* @returns {React.ReactElement<*> | null}
*/
_renderPrivateReplyButton() {
const { message, canReply } = this.props;
const { lobbyChat } = message;
if (!canReply) {
return null;
}
return (
<View style = { styles.replyContainer as ViewStyle }>
<PrivateMessageButton
isLobbyMessage = { lobbyChat }
participantID = { message.participantId }
reply = { true }
showLabel = { false }
toggledStyles = { styles.replyStyles } />
</View>
);
}
/**
* Renders the time at which the message was sent, if necessary.
*
* @returns {React.ReactElement<*> | null}
*/
_renderTimestamp() {
if (!this.props.showTimestamp) {
return null;
}
return (
<Text style = { styles.timeText }>
{ getFormattedTimestamp(this.props.message) }
</Text>
);
}
}
/**
* Maps part of the redux state to the props of this component.
*
* @param {Object} state - The Redux state.
* @param {IChatMessageProps} message - Message object.
* @returns {IProps}
*/
function _mapStateToProps(state: IReduxState, { message }: IChatMessageProps) {
const activeMatch = getActiveChatSearchMatch(state);
return {
canReply: getCanReplyToMessage(state, message),
gifEnabled: isGifEnabled(state),
highlightQuery: getChatSearchQuery(state),
isActiveMatch: Boolean(activeMatch && activeMatch.messageId === message.messageId),
knocking: state['features/lobby'].knocking
};
}
export default translate(connect(_mapStateToProps)(ChatMessage));

View File

@@ -0,0 +1,81 @@
import React, { Component } from 'react';
import { FlatList } from 'react-native';
import { MESSAGE_TYPE_LOCAL, MESSAGE_TYPE_REMOTE } from '../../constants';
import { IMessage } from '../../types';
import ChatMessage from './ChatMessage';
interface IProps {
/**
* The messages array to render.
*/
messages: Array<IMessage>;
}
/**
* Implements a container to render all the chat messages in a conference.
*/
export default class ChatMessageGroup extends Component<IProps> {
/**
* Instantiates a new instance of the component.
*
* @inheritdoc
*/
constructor(props: IProps) {
super(props);
this._keyExtractor = this._keyExtractor.bind(this);
this._renderMessage = this._renderMessage.bind(this);
}
/**
* Implements {@code Component#render}.
*
* @inheritdoc
*/
override render() {
return (
<FlatList
data = { this.props.messages }
inverted = { true }
keyExtractor = { this._keyExtractor }
renderItem = { this._renderMessage } />
);
}
/**
* Key extractor for the flatlist.
*
* @param {Object} _item - The flatlist item that we need the key to be
* generated for.
* @param {number} index - The index of the element.
* @returns {string}
*/
_keyExtractor(_item: Object, index: number) {
return `key_${index}`;
}
/**
* Renders a single chat message.
*
* @param {Object} message - The chat message to render.
* @returns {React$Element<*>}
*/
_renderMessage({ index, item: message }: { index: number; item: IMessage; }) {
return (
<ChatMessage
message = { message }
showAvatar = {
this.props.messages[0].messageType !== MESSAGE_TYPE_LOCAL
&& index === this.props.messages.length - 1
}
showDisplayName = {
this.props.messages[0].messageType === MESSAGE_TYPE_REMOTE
&& index === this.props.messages.length - 1
}
showTimestamp = { index === 0 } />
);
}
}

View File

@@ -0,0 +1,30 @@
import React from 'react';
import { connect } from 'react-redux';
import ConfirmDialog from '../../../base/dialog/components/native/ConfirmDialog';
import { translate } from '../../../base/i18n/functions';
import { AbstractChatPrivacyDialog, _mapDispatchToProps, _mapStateToProps } from '../AbstractChatPrivacyDialog';
/**
* Implements a component for the dialog displayed to avoid mis-sending private messages.
*/
class ChatPrivacyDialog extends AbstractChatPrivacyDialog {
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
return (
<ConfirmDialog
cancelLabel = 'dialog.sendPrivateMessageCancel'
confirmLabel = 'dialog.sendPrivateMessageOk'
descriptionKey = 'dialog.sendPrivateMessage'
onCancel = { this._onSendGroupMessage }
onSubmit = { this._onSendPrivateMessage } />
);
}
}
export default translate(connect(_mapStateToProps, _mapDispatchToProps)(ChatPrivacyDialog));

View File

@@ -0,0 +1,107 @@
import React, { useCallback } from 'react';
import { Text, TouchableOpacity, View, ViewStyle } from 'react-native';
import { useDispatch, useSelector } from 'react-redux';
import { IReduxState } from '../../../app/types';
import { translate } from '../../../base/i18n/functions.native';
import Icon from '../../../base/icons/components/Icon';
import { IconArrowDown, IconArrowUp } from '../../../base/icons/svg';
import BaseTheme from '../../../base/ui/components/BaseTheme.native';
import Input from '../../../base/ui/components/native/Input';
import { setChatSearchMatchIndex, setChatSearchQuery } from '../../actions.native';
import { getChatSearchMatchIndex, getChatSearchMatches, getChatSearchQuery } from '../../functions';
import styles from './styles';
interface IProps {
t: Function;
}
/**
* Renders an always-visible search bar above the chat message list, allowing
* the user to filter messages and step through matches.
*
* @returns {React.ReactElement}
*/
const ChatSearchBar = ({ t }: IProps) => {
const dispatch = useDispatch();
const query = useSelector((state: IReduxState) => getChatSearchQuery(state));
const matches = useSelector((state: IReduxState) => getChatSearchMatches(state));
const matchIndex = useSelector((state: IReduxState) => getChatSearchMatchIndex(state));
const hasQuery = query.trim().length > 0;
const hasMatches = matches.length > 0;
const _onChangeText = useCallback((text: string) => {
dispatch(setChatSearchQuery(text));
}, [ dispatch ]);
const _onPrevious = useCallback(() => {
if (!matches.length) {
return;
}
dispatch(setChatSearchMatchIndex((matchIndex - 1 + matches.length) % matches.length));
}, [ dispatch, matchIndex, matches.length ]);
const _onNext = useCallback(() => {
if (!matches.length) {
return;
}
dispatch(setChatSearchMatchIndex((matchIndex + 1) % matches.length));
}, [ dispatch, matchIndex, matches.length ]);
const _onSubmitEditing = useCallback(() => {
_onNext();
}, [ _onNext ]);
return (
<View style = { styles.searchBarContainer as ViewStyle }>
<Input
clearable = { true }
customStyles = {{
clearButton: styles.searchClearButton,
container: styles.searchInputContainer,
input: styles.searchInput
}}
onChange = { _onChangeText }
onSubmitEditing = { _onSubmitEditing }
placeholder = { t('chat.search.placeholder') }
returnKeyType = 'search'
value = { query } />
{ hasQuery && (
<>
<Text style = { styles.searchBarCounter }>
{ hasMatches
? t('chat.search.resultsCount', {
current: matchIndex + 1,
total: matches.length
})
: t('chat.search.noResults') }
</Text>
<TouchableOpacity
disabled = { !hasMatches }
onPress = { _onPrevious }
style = { styles.searchBarNavButton as ViewStyle }>
<Icon
color = { hasMatches ? BaseTheme.palette.icon01 : BaseTheme.palette.icon03 }
size = { 18 }
src = { IconArrowUp } />
</TouchableOpacity>
<TouchableOpacity
disabled = { !hasMatches }
onPress = { _onNext }
style = { styles.searchBarNavButton as ViewStyle }>
<Icon
color = { hasMatches ? BaseTheme.palette.icon01 : BaseTheme.palette.icon03 }
size = { 18 }
src = { IconArrowDown } />
</TouchableOpacity>
</>
) }
</View>
);
};
export default translate(ChatSearchBar);

View File

@@ -0,0 +1,125 @@
import { useNavigation } from '@react-navigation/native';
import React, { useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { TouchableHighlight, View, ViewStyle } from 'react-native';
import { Text } from 'react-native-paper';
import { useSelector } from 'react-redux';
import { IReduxState } from '../../../app/types';
import Icon from '../../../base/icons/components/Icon';
import { IconArrowRight, IconSubtitles } from '../../../base/icons/svg';
import JitsiScreen from '../../../base/modal/components/JitsiScreen';
import { StyleType } from '../../../base/styles/functions.any';
import BaseTheme from '../../../base/ui/components/BaseTheme.native';
import Button from '../../../base/ui/components/native/Button';
import { BUTTON_TYPES } from '../../../base/ui/constants.native';
import { TabBarLabelCounter } from '../../../mobile/navigation/components/TabBarLabelCounter';
import { navigate } from '../../../mobile/navigation/components/conference/ConferenceNavigationContainerRef';
import { screen } from '../../../mobile/navigation/routes';
import { ChatTabs } from '../../constants';
import AbstractClosedCaptions, { AbstractProps } from '../AbstractClosedCaptions';
import { SubtitlesMessagesContainer } from './SubtitlesMessagesContainer';
import { closedCaptionsStyles } from './styles';
/**
* Component that displays the closed captions interface.
*
* @returns {JSX.Element} - The ClosedCaptions component.
*/
const ClosedCaptions = ({
canStartSubtitles,
filteredSubtitles,
groupedSubtitles,
isButtonPressed,
isTranscribing,
startClosedCaptions
}: AbstractProps): JSX.Element => {
const navigation = useNavigation();
const { t } = useTranslation();
const isCCTabFocused = useSelector((state: IReduxState) => state['features/chat'].focusedTab === ChatTabs.CLOSED_CAPTIONS);
const selectedLanguage = useSelector((state: IReduxState) => state['features/subtitles']._language);
const navigateToLanguageSelect = useCallback(() => {
navigate(screen.conference.subtitles);
}, [ navigation, screen ]);
useEffect(() => {
navigation?.setOptions({
tabBarLabel: () => (
<TabBarLabelCounter
isFocused = { isCCTabFocused }
label = { t('chat.tabs.closedCaptions') } />
)
});
}, [ isCCTabFocused, navigation, t ]);
const getContentContainerStyle = () => {
if (isTranscribing) {
return closedCaptionsStyles.transcribingContainer as StyleType;
}
return closedCaptionsStyles.emptyContentContainer as StyleType;
};
const renderContent = () => {
if (!isTranscribing) {
if (canStartSubtitles) {
return (
<View style = { closedCaptionsStyles.emptyContent as ViewStyle }>
<Button
accessibilityLabel = { t('closedCaptionsTab.startClosedCaptionsButton') }
disabled = { isButtonPressed }
labelKey = 'closedCaptionsTab.startClosedCaptionsButton'
onClick = { startClosedCaptions }
type = { BUTTON_TYPES.PRIMARY } />
</View>
);
}
return (
<View style = { closedCaptionsStyles.emptyContent as ViewStyle }>
<Icon
color = { BaseTheme.palette.icon03 }
size = { 100 }
src = { IconSubtitles } />
<Text style = { [ closedCaptionsStyles.emptyStateText, { marginTop: BaseTheme.spacing[3] } ] }>
{ t('closedCaptionsTab.emptyState') }
</Text>
</View>
);
}
return (
<>
<View style = { closedCaptionsStyles.languageButtonContainer as ViewStyle }>
<Text style = { closedCaptionsStyles.languageButtonText }>{ t('transcribing.translateTo') }:</Text>
<TouchableHighlight onPress = { navigateToLanguageSelect }>
<View style = { closedCaptionsStyles.languageButtonContent as ViewStyle }>
<Text style = { closedCaptionsStyles.languageButtonText }>{ t(selectedLanguage ?? 'transcribing.subtitlesOff') }</Text>
<Icon
size = { 24 }
src = { IconArrowRight } />
</View>
</TouchableHighlight>
</View>
<View style = { closedCaptionsStyles.messagesContainer as ViewStyle }>
<SubtitlesMessagesContainer
groups = { groupedSubtitles }
messages = { filteredSubtitles } />
</View>
</>
);
};
return (
<JitsiScreen
contentContainerStyle = { getContentContainerStyle() }
disableForcedKeyboardDismiss = { true }
hasExtraHeaderHeight = { true }
style = { closedCaptionsStyles.container as StyleType }>
{ renderContent() }
</JitsiScreen>
);
};
export default AbstractClosedCaptions(ClosedCaptions);

View File

@@ -0,0 +1,35 @@
import { GiphyMediaView } from '@giphy/react-native-sdk';
import React from 'react';
import { Image, ImageStyle, View } from 'react-native';
import { extractGifMediaId, extractGifURL } from '../../../gifs/function.any';
import styles from './styles';
interface IProps {
/**
* The formatted gif message.
*/
message: string;
}
const GifMessage = ({ message }: IProps) => {
const url = extractGifURL(message);
const mediaId = extractGifMediaId(url);
return (<View
id = 'gif-message'
style = { styles.gifContainer }>
{mediaId
? <GiphyMediaView
media = {{ id: mediaId }}
resizeMode = 'contain'
style = { styles.gifMedia } />
: <Image
source = {{ uri: url }}
style = { styles.gifImage as ImageStyle } />}
</View>);
};
export default GifMessage;

View File

@@ -0,0 +1,227 @@
import React, { Component } from 'react';
import { FlatList, Text, TextStyle, View, ViewStyle } from 'react-native';
import { connect } from 'react-redux';
import { IReduxState } from '../../../app/types';
import { translate } from '../../../base/i18n/functions.native';
import { groupMessagesBySender } from '../../../base/util/messageGrouping';
import { MESSAGE_TYPE_LOCAL, MESSAGE_TYPE_REMOTE } from '../../constants';
import { getActiveChatSearchMatch } from '../../functions';
import { IMessage } from '../../types';
import ChatMessage from './ChatMessage';
import styles from './styles';
/**
* A single flat list row: one message plus its grouping flags.
*/
interface IMessageRow {
message: IMessage;
showAvatar: boolean;
showDisplayName: boolean;
showTimestamp: boolean;
}
interface IProps {
_activeMatch?: IMessage;
messages: IMessage[];
t: Function;
}
/**
* Implements a container to render all the chat messages in a conference.
*/
class MessageContainer extends Component<IProps, any> {
static defaultProps = {
messages: [] as IMessage[]
};
_flatListRef: React.RefObject<FlatList<any>>;
/**
* Instantiates a new instance of the component.
*
* @inheritdoc
*/
constructor(props: IProps) {
super(props);
this._flatListRef = React.createRef();
this._keyExtractor = this._keyExtractor.bind(this);
this._renderListEmptyComponent = this._renderListEmptyComponent.bind(this);
this._renderMessage = this._renderMessage.bind(this);
this._getMessageRows = this._getMessageRows.bind(this);
this._onScrollToIndexFailed = this._onScrollToIndexFailed.bind(this);
}
/**
* Implements {@code Component#componentDidUpdate}.
*
* @inheritdoc
*/
override componentDidUpdate(prevProps: IProps) {
const { _activeMatch } = this.props;
if (_activeMatch && _activeMatch.messageId !== prevProps._activeMatch?.messageId) {
this._scrollToActiveMatch();
}
}
/**
* Scrolls the list so the active search match message is visible.
*
* @returns {void}
*/
_scrollToActiveMatch() {
const { _activeMatch } = this.props;
if (!_activeMatch) {
return;
}
const data = this._getMessageRows();
const index = data.findIndex(row => row.message.messageId === _activeMatch.messageId);
if (index === -1) {
return;
}
requestAnimationFrame(() => {
this._flatListRef.current?.scrollToIndex({
animated: true,
index,
viewPosition: 0.5
});
});
}
/**
* Fallback for when scrollToIndex fires before the target row is measured.
*
* @param {Object} info - Info about the failed scroll attempt.
* @returns {void}
*/
_onScrollToIndexFailed(info: { averageItemLength: number; index: number; }) {
// Jump near the target so it renders, then retry.
this._flatListRef.current?.scrollToOffset({
animated: false,
offset: info.averageItemLength * info.index
});
setTimeout(() => {
this._flatListRef.current?.scrollToIndex({
animated: true,
index: info.index,
viewPosition: 0.5
});
}, 100);
}
/**
* Implements {@code Component#render}.
*
* @inheritdoc
*/
override render() {
const data = this._getMessageRows();
const noMessages = data.length === 0;
return (
<FlatList
ListEmptyComponent = { this._renderListEmptyComponent }
bounces = { false }
// @ts-ignore
contentContainerStyle = { noMessages && styles.emptyListContentContainer }
data = { data }
// Workaround for RN bug:
// https://github.com/facebook/react-native/issues/21196
inverted = { Boolean(data.length) }
keyExtractor = { this._keyExtractor }
keyboardShouldPersistTaps = 'handled'
onScrollToIndexFailed = { this._onScrollToIndexFailed }
ref = { this._flatListRef }
renderItem = { this._renderMessage }
style = { noMessages && styles.emptyListStyle } />
);
}
/**
* Key extractor for the flatlist.
*
* @param {IMessageRow} item - The row whose key we need to generate.
* @returns {string}
*/
_keyExtractor(item: IMessageRow) {
return item.message.messageId;
}
/**
* Renders a message when there are no messages in the chat yet.
*
* @returns {React$Element<any>}
*/
_renderListEmptyComponent() {
const { t } = this.props;
return (
<View
id = 'no-messages-message'
style = { styles.emptyComponentWrapper as ViewStyle }>
<Text style = { styles.emptyComponentText as TextStyle }>
{ t('chat.noMessagesMessage') }
</Text>
</View>
);
}
/**
* Renders a single chat message.
*
* @param {Object} row - The row containing the message and its grouping flags.
* @returns {React$Element<*>}
*/
_renderMessage({ item }: { item: IMessageRow; }) {
return (
<ChatMessage
message = { item.message }
showAvatar = { item.showAvatar }
showDisplayName = { item.showDisplayName }
showTimestamp = { item.showTimestamp } />
);
}
/**
* Builds one row per message with its grouping flags.
*
* @returns {IMessageRow[]}
*/
_getMessageRows(): IMessageRow[] {
const rows: IMessageRow[] = [];
for (const group of groupMessagesBySender(this.props.messages)) {
const groupType = group.messages[0].messageType;
const oldestIndex = group.messages.length - 1;
group.messages.forEach((message, index) => {
rows.push({
message,
showAvatar: groupType !== MESSAGE_TYPE_LOCAL && index === oldestIndex,
showDisplayName: groupType === MESSAGE_TYPE_REMOTE && index === oldestIndex,
showTimestamp: index === 0
});
});
}
return rows;
}
}
function _mapStateToProps(state: IReduxState) {
return {
_activeMatch: getActiveChatSearchMatch(state)
};
}
export default translate(connect(_mapStateToProps)(MessageContainer));

View File

@@ -0,0 +1,163 @@
import React from 'react';
import { Text, TouchableHighlight, View, ViewStyle } from 'react-native';
import { connect } from 'react-redux';
import { IReduxState, IStore } from '../../../app/types';
import { translate } from '../../../base/i18n/functions';
import Icon from '../../../base/icons/components/Icon';
import { IconCloseLarge } from '../../../base/icons/svg';
import { ILocalParticipant } from '../../../base/participants/types';
import {
setParams
} from '../../../mobile/navigation/components/conference/ConferenceNavigationContainerRef';
import { setLobbyChatActiveState, setPrivateMessageRecipient } from '../../actions.any';
import AbstractMessageRecipient, {
IProps as AbstractProps,
_mapStateToProps as _mapStateToPropsAbstract
} from '../AbstractMessageRecipient';
import styles from './styles';
interface IProps extends AbstractProps {
/**
* The Redux dispatch function.
*/
dispatch: IStore['dispatch'];
/**
* Is lobby messaging active.
*/
isLobbyChatActive: boolean;
/**
* The participant string for lobby chat messaging.
*/
lobbyMessageRecipient?: {
id: string;
name: string;
} | ILocalParticipant;
}
/**
* Class to implement the displaying of the recipient of the next message.
*/
class MessageRecipient extends AbstractMessageRecipient<IProps> {
/**
* Constructor of the component.
*
* @param {IProps} props - The props of the component.
*/
constructor(props: IProps) {
super(props);
this._onResetPrivateMessageRecipient = this._onResetPrivateMessageRecipient.bind(this);
this._onResetLobbyMessageRecipient = this._onResetLobbyMessageRecipient.bind(this);
}
/**
* Resets lobby message recipient from state.
*
* @returns {void}
*/
_onResetLobbyMessageRecipient() {
const { dispatch } = this.props;
dispatch(setLobbyChatActiveState(false));
}
/**
* Resets private message recipient from state.
*
* @returns {void}
*/
_onResetPrivateMessageRecipient() {
const { dispatch } = this.props;
dispatch(setPrivateMessageRecipient());
setParams({
privateMessageRecipient: undefined
});
}
/**
* Implements {@code PureComponent#render}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
const {
isLobbyChatActive,
lobbyMessageRecipient,
_privateMessageRecipient,
_isVisitor,
t
} = this.props;
if (isLobbyChatActive) {
return (
<View
id = 'chat-recipient'
style = { styles.lobbyMessageRecipientContainer as ViewStyle }>
<Text style = { styles.messageRecipientText }>
{ t('chat.lobbyChatMessageTo', {
recipient: lobbyMessageRecipient?.name
}) }
</Text>
<TouchableHighlight
onPress = { this._onResetLobbyMessageRecipient }>
<Icon
src = { IconCloseLarge }
style = { styles.messageRecipientCancelIcon } />
</TouchableHighlight>
</View>
);
}
if (!_privateMessageRecipient) {
return null;
}
return (
<View
id = 'message-recipient'
style = { styles.messageRecipientContainer as ViewStyle }>
<Text style = { styles.messageRecipientText }>
{ t('chat.messageTo', {
recipient: `${_privateMessageRecipient}${_isVisitor ? ` ${t('visitors.chatIndicator')}` : ''}`
}) }
</Text>
<TouchableHighlight
id = 'message-recipient-cancel-button'
onPress = { this._onResetPrivateMessageRecipient }
underlayColor = { 'transparent' }>
<Icon
src = { IconCloseLarge }
style = { styles.messageRecipientCancelIcon } />
</TouchableHighlight>
</View>
);
}
}
/**
* Maps part of the redux state to the props of this component.
*
* @param {Object} state - The Redux state.
* @param {any} _ownProps - Component's own props.
* @returns {IProps}
*/
function _mapStateToProps(state: IReduxState, _ownProps: any) {
const { lobbyMessageRecipient, isLobbyChatActive } = state['features/chat'];
return {
..._mapStateToPropsAbstract(state, _ownProps),
isLobbyChatActive,
lobbyMessageRecipient
};
}
export default translate(connect(_mapStateToProps)(MessageRecipient));

View File

@@ -0,0 +1,110 @@
import { connect } from 'react-redux';
import { IReduxState } from '../../../app/types';
import { CHAT_ENABLED } from '../../../base/flags/constants';
import { getFeatureFlag } from '../../../base/flags/functions';
import { translate } from '../../../base/i18n/functions';
import { IconMessage, IconReply } from '../../../base/icons/svg';
import { getParticipantById } from '../../../base/participants/functions';
import { IParticipant } from '../../../base/participants/types';
import AbstractButton, { IProps as AbstractButtonProps } from '../../../base/toolbox/components/AbstractButton';
import { arePollsDisabled } from '../../../conference/functions.any';
import { navigate } from '../../../mobile/navigation/components/conference/ConferenceNavigationContainerRef';
import { screen } from '../../../mobile/navigation/routes';
import { handleLobbyChatInitialized, openChat } from '../../actions.native';
export interface IProps extends AbstractButtonProps {
/**
* True if message is a lobby chat message.
*/
_isLobbyMessage: boolean;
/**
* True if the polls feature is disabled.
*/
_isPollsDisabled?: boolean;
/**
* The participant object retrieved from Redux.
*/
_participant?: IParticipant;
/**
* The ID of the participant that the message is to be sent.
*/
participantID: string;
/**
* True if the button is rendered as a reply button.
*/
reply: boolean;
}
/**
* Class to render a button that initiates the sending of a private message through chat.
*/
class PrivateMessageButton extends AbstractButton<IProps, any> {
override accessibilityLabel = 'toolbar.accessibilityLabel.privateMessage';
override icon = IconMessage;
override label = 'toolbar.privateMessage';
override toggledIcon = IconReply;
/**
* Handles clicking / pressing the button.
*
* @private
* @returns {void}
*/
override _handleClick() {
if (this.props._isLobbyMessage) {
this.props.dispatch(handleLobbyChatInitialized(this.props.participantID));
}
this.props.dispatch(openChat(this.props._participant));
this.props._isPollsDisabled
? navigate(screen.conference.chat, {
privateMessageRecipient: this.props._participant
})
: navigate(screen.conference.chatTabs.main, {
screen: screen.conference.chatTabs.tab.chat,
params: {
privateMessageRecipient: this.props._participant
}
});
}
/**
* Helper function to be implemented by subclasses, which must return a
* {@code boolean} value indicating if this button is toggled or not.
*
* @protected
* @returns {boolean}
*/
override _isToggled() {
return this.props.reply;
}
}
/**
* Maps part of the Redux store to the props of this component.
*
* @param {Object} state - The Redux state.
* @param {IProps} ownProps - The own props of the component.
* @returns {IProps}
*/
export function _mapStateToProps(state: IReduxState, ownProps: any) {
const enabled = getFeatureFlag(state, CHAT_ENABLED, true);
const { visible = enabled, isLobbyMessage, participantID } = ownProps;
return {
_isPollsDisabled: arePollsDisabled(state),
_participant: getParticipantById(state, participantID),
_isLobbyMessage: isLobbyMessage,
visible
};
}
export default translate(connect(_mapStateToProps)(PrivateMessageButton));

View File

@@ -0,0 +1,45 @@
import React from 'react';
import { Text, View, ViewStyle } from 'react-native';
import { useSelector } from 'react-redux';
import { IReduxState } from '../../../app/types';
import { getParticipantDisplayName } from '../../../base/participants/functions';
import { ISubtitle } from '../../../subtitles/types';
import { closedCaptionsStyles } from './styles';
interface IProps extends ISubtitle {
showDisplayName: boolean;
}
export default function SubtitleMessage({ participantId, text, timestamp, interim, showDisplayName }: IProps) {
const participantName = useSelector((state: IReduxState) =>
getParticipantDisplayName(state, participantId));
const containerStyle: ViewStyle[] = [
closedCaptionsStyles.subtitleMessageContainer as ViewStyle
];
if (interim) {
containerStyle.push(closedCaptionsStyles.subtitleMessageInterim as ViewStyle);
}
return (
<View style = { containerStyle }>
<View style = { closedCaptionsStyles.subtitleMessageContent as ViewStyle }>
{
showDisplayName && (
<Text style = { closedCaptionsStyles.subtitleMessageHeader }>
{ participantName }
</Text>
)
}
<Text style = { closedCaptionsStyles.subtitleMessageText }>{ text }</Text>
<Text style = { closedCaptionsStyles.subtitleMessageTimestamp }>
{ new Date(timestamp).toLocaleTimeString() }
</Text>
</View>
</View>
);
}

View File

@@ -0,0 +1,40 @@
import React from 'react';
import { View, ViewStyle } from 'react-native';
import Avatar from '../../../base/avatar/components/Avatar';
import { ISubtitle } from '../../../subtitles/types';
import SubtitleMessage from './SubtitleMessage';
import { closedCaptionsStyles } from './styles';
interface IProps {
messages: ISubtitle[];
senderId: string;
}
export function SubtitlesGroup({ messages, senderId }: IProps) {
if (!messages.length) {
return null;
}
return (
<View style = { closedCaptionsStyles.subtitlesGroupContainer as ViewStyle }>
<View style = { closedCaptionsStyles.subtitlesGroupAvatar as ViewStyle }>
<Avatar
participantId = { senderId }
size = { 32 } />
</View>
<View style = { closedCaptionsStyles.subtitlesGroupMessagesContainer as ViewStyle }>
{
messages.map((message, index) => (
<SubtitleMessage
key = { `${message.timestamp}-${message.id}` }
showDisplayName = { index === 0 }
{ ...message } />
))
}
</View>
</View>
);
}

View File

@@ -0,0 +1,105 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { NativeScrollEvent, NativeSyntheticEvent, ScrollView, View, ViewStyle } from 'react-native';
import Icon from '../../../base/icons/components/Icon';
import { IconArrowDown } from '../../../base/icons/svg';
import BaseTheme from '../../../base/ui/components/BaseTheme.native';
import Button from '../../../base/ui/components/native/Button';
import { BUTTON_TYPES } from '../../../base/ui/constants.native';
import { ISubtitle } from '../../../subtitles/types';
import { SubtitlesGroup } from './SubtitlesGroup';
import { closedCaptionsStyles } from './styles';
/**
* The threshold value used to determine if the user is at the bottom of the scroll view.
*/
const SCROLL_THRESHOLD = 50;
interface IProps {
groups: Array<{
messages: ISubtitle[];
senderId: string;
}>;
messages: ISubtitle[];
}
export function SubtitlesMessagesContainer({ messages, groups }: IProps) {
const [ hasNewMessages, setHasNewMessages ] = useState(false);
const [ isScrolledToBottom, setIsScrolledToBottom ] = useState(true);
const scrollViewRef = useRef<ScrollView>(null);
const previousMessages = useRef(messages);
const scrollToBottom = useCallback((withAnimation: boolean) => {
scrollViewRef.current?.scrollToEnd({ animated: withAnimation });
}, []);
const handleNewMessagesClick = useCallback(() => {
scrollToBottom(true);
}, [ scrollToBottom ]);
const handleScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
const isAtBottom = contentOffset.y + layoutMeasurement.height >= contentSize.height - SCROLL_THRESHOLD;
setIsScrolledToBottom(isAtBottom);
if (isAtBottom) {
setHasNewMessages(false);
}
}, []);
useEffect(() => {
scrollToBottom(false);
}, [ scrollToBottom ]);
useEffect(() => {
const newMessages = messages.filter(message => !previousMessages.current.includes(message));
if (newMessages.length > 0) {
if (isScrolledToBottom) {
scrollToBottom(false);
} else {
setHasNewMessages(true);
}
}
previousMessages.current = messages;
}, [ messages, scrollToBottom ]);
return (
<View style = { closedCaptionsStyles.subtitlesMessagesContainer as ViewStyle }>
<ScrollView
contentContainerStyle = { closedCaptionsStyles.subtitlesMessagesList as ViewStyle }
onScroll = { handleScroll }
ref = { scrollViewRef }
scrollEventThrottle = { 16 }>
{
groups.map(group => (
<SubtitlesGroup
key = { `${group.senderId}-${group.messages[0]?.timestamp}` }
messages = { group.messages }
senderId = { group.senderId } />
))
}
</ScrollView>
{
!isScrolledToBottom && hasNewMessages && (
<View style = { closedCaptionsStyles.newMessagesButtonContainer as ViewStyle }>
<Button
accessibilityLabel = 'chat.newMessages'
// eslint-disable-next-line react/jsx-no-bind
icon = { () => (
<Icon
color = { BaseTheme.palette.icon04 }
size = { 20 }
src = { IconArrowDown } />
) }
labelKey = 'chat.newMessages'
onClick = { handleNewMessagesClick }
type = { BUTTON_TYPES.SECONDARY } />
</View>
)
}
</View>
);
}

View File

@@ -0,0 +1,462 @@
import { BoxModel } from '../../../base/styles/components/styles/BoxModel';
import BaseTheme from '../../../base/ui/components/BaseTheme.native';
const BUBBLE_RADIUS = 8;
const recipientContainer = {
alignItems: 'center',
backgroundColor: BaseTheme.palette.support05,
borderRadius: BaseTheme.shape.borderRadius,
flexDirection: 'row',
height: 48,
marginBottom: BaseTheme.spacing[3],
marginHorizontal: BaseTheme.spacing[3],
padding: BaseTheme.spacing[2]
};
/**
* The styles of the feature chat.
*
* NOTE: Sizes and colors come from the 8x8 guidelines. This is the first
* component to receive this treating, if others happen to have similar, we
* need to extract the brand colors and sizes into a branding feature (planned
* for the future).
*/
export default {
/**
* Background of the chat screen.
*/
backdrop: {
backgroundColor: BaseTheme.palette.ui10,
flex: 1
},
chatDisabled: {
padding: BaseTheme.spacing[2],
textAlign: 'center'
},
emptyComponentText: {
...BaseTheme.typography.bodyLongBold,
color: BaseTheme.palette.text02,
textAlign: 'center'
},
lobbyMessageBubble: {
backgroundColor: BaseTheme.palette.support06
},
lobbyMsgNotice: {
color: BaseTheme.palette.text04,
fontSize: 11,
marginTop: 6
},
privateNotice: {
...BaseTheme.palette.bodyShortRegular,
color: BaseTheme.palette.text02
},
privateMessageBubble: {
backgroundColor: BaseTheme.palette.support05
},
remoteMessageBubble: {
backgroundColor: BaseTheme.palette.ui02,
borderTopLeftRadius: 0
},
replyContainer: {
alignSelf: 'stretch',
justifyContent: 'center'
},
replyStyles: {
iconStyle: {
color: BaseTheme.palette.icon01,
fontSize: 22,
padding: BaseTheme.spacing[2]
},
underlayColor: 'transparent'
},
activeMatchMessageBubble: {
backgroundColor: BaseTheme.palette.warning02
},
chatMessageHighlight: {
backgroundColor: BaseTheme.palette.action02,
color: BaseTheme.palette.text01
},
chatMessageHighlightActive: {
backgroundColor: BaseTheme.palette.action01,
color: BaseTheme.palette.text01
},
searchBarContainer: {
alignItems: 'center',
backgroundColor: BaseTheme.palette.ui03,
borderColor: BaseTheme.palette.ui03,
borderRadius: BaseTheme.shape.borderRadius,
borderWidth: 2,
flexDirection: 'row',
height: 40,
marginBottom: BaseTheme.spacing[1],
marginHorizontal: BaseTheme.spacing[3],
marginTop: BaseTheme.spacing[2],
paddingHorizontal: BaseTheme.spacing[2]
},
searchBarCounter: {
...BaseTheme.typography.labelRegular,
color: BaseTheme.palette.text03,
marginHorizontal: BaseTheme.spacing[1]
},
searchBarNavButton: {
alignItems: 'center',
alignSelf: 'stretch',
justifyContent: 'center',
padding: BaseTheme.spacing[1]
},
searchClearButton: {
alignItems: 'center',
height: 40,
justifyContent: 'center',
top: 0
},
searchInputContainer: {
flex: 1,
marginBottom: 0
},
searchInput: {
backgroundColor: 'transparent',
borderWidth: 0,
height: 40,
paddingHorizontal: 0
},
/**
* Wrapper View for the avatar.
*/
avatarWrapper: {
marginRight: BaseTheme.spacing[2],
width: 32
},
chatLink: {
color: BaseTheme.palette.link01
},
chatMessage: {
...BaseTheme.typography.bodyShortRegular,
color: BaseTheme.palette.text01
},
/**
* Wrapper for the details together, such as name, message and time.
*/
detailsWrapper: {
alignItems: 'flex-start',
flex: 1,
flexDirection: 'column'
},
emptyComponentWrapper: {
alignItems: 'center',
justifyContent: 'center',
padding: BoxModel.padding,
maxWidth: '80%'
},
emptyListStyle: {
flex: 1
},
emptyListContentContainer: {
alignItems: 'center',
flex: 1,
justifyContent: 'center'
},
disabledSendWrapper: {
alignSelf: 'center',
flex: 0,
padding: BoxModel.padding,
paddingBottom: '8%',
paddingTop: '8%',
maxWidth: '80%'
},
/**
* A special padding to avoid issues on some devices (such as Android devices with custom suggestions bar).
*/
extraBarPadding: {
paddingBottom: BaseTheme.spacing[6]
},
inputBar: {
alignSelf: 'stretch',
flexDirection: 'row',
width: '100%'
},
sendButton: {
marginRight: BaseTheme.spacing[4],
marginLeft: BaseTheme.spacing[2]
},
customInputContainer: {
marginLeft: BaseTheme.spacing[4],
flex: 1
},
messageBubble: {
alignItems: 'center',
borderRadius: BUBBLE_RADIUS,
flexDirection: 'row'
},
/**
* Wrapper View for the entire block.
*/
messageWrapper: {
alignItems: 'flex-start',
flex: 1,
flexDirection: 'row',
marginHorizontal: 17,
marginVertical: 4
},
/**
* Style modifier for the {@code detailsWrapper} for own messages.
*/
ownMessageDetailsWrapper: {
alignItems: 'flex-end'
},
replyWrapper: {
alignItems: 'center',
flexDirection: 'row'
},
/**
* Style modifier for system (error) messages.
*/
systemMessageBubble: {
backgroundColor: 'rgb(247, 215, 215)'
},
/**
* Wrapper for the name and the message text.
*/
textWrapper: {
alignItems: 'flex-start',
flexDirection: 'column',
padding: 9
},
/**
* Text node for the timestamp.
*/
timeText: {
color: BaseTheme.palette.text03,
fontSize: 13
},
tabContainer: {
flexDirection: 'row',
justifyContent: 'center'
},
tabLeftButton: {
flex: 1,
borderTopLeftRadius: 0,
borderTopRightRadius: 0,
borderBottomLeftRadius: 0
},
tabRightButton: {
flex: 1,
borderTopLeftRadius: 0,
borderTopRightRadius: 0,
borderBottomRightRadius: 0
},
gifContainer: {
maxHeight: 150
},
gifImage: {
flexGrow: 1,
height: undefined,
resizeMode: 'contain',
width: 250
},
gifMedia: {
height: 150,
resizeMode: 'contain',
width: 250,
},
senderDisplayName: {
...BaseTheme.typography.bodyShortBold,
color: BaseTheme.palette.text02
},
localMessageBubble: {
backgroundColor: BaseTheme.palette.ui04,
borderTopRightRadius: 0
},
lobbyMessageRecipientContainer: {
...recipientContainer,
backgroundColor: BaseTheme.palette.support06
},
messageRecipientCancelIcon: {
color: BaseTheme.palette.icon01,
fontSize: 18
},
messageRecipientContainer: {
...recipientContainer
},
messageRecipientText: {
...BaseTheme.typography.bodyShortRegular,
color: BaseTheme.palette.text01,
flex: 1
}
};
/**
* Styles for the ClosedCaptions component.
*/
export const closedCaptionsStyles = {
container: {
backgroundColor: BaseTheme.palette.ui01,
flex: 1
},
emptyContentContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center'
},
emptyContent: {
alignItems: 'center',
justifyContent: 'center',
flex: 1,
padding: BaseTheme.spacing[3]
},
emptyStateText: {
...BaseTheme.typography.bodyLongBold,
color: BaseTheme.palette.text02,
textAlign: 'center',
maxWidth: '80%'
},
transcribingContainer: {
flex: 1
},
languageButtonContainer: {
justifyContent: 'space-between',
flexDirection: 'row',
padding: BaseTheme.spacing[3]
},
languageButtonText: {
...BaseTheme.typography.bodyShortRegularLarge,
color: BaseTheme.palette.text01,
marginHorizontal: BaseTheme.spacing[2]
},
languageButtonContent: {
flexDirection: 'row'
},
subtitleMessageContainer: {
backgroundColor: BaseTheme.palette.ui02,
borderRadius: BaseTheme.shape.borderRadius,
padding: BaseTheme.spacing[2],
maxWidth: '100%',
marginTop: BaseTheme.spacing[1]
},
subtitleMessageContent: {
maxWidth: '100%',
flex: 1
},
subtitleMessageHeader: {
...BaseTheme.typography.labelBold,
color: BaseTheme.palette.text02,
marginBottom: BaseTheme.spacing[1],
maxWidth: 130
},
subtitleMessageText: {
...BaseTheme.typography.bodyShortRegular,
color: BaseTheme.palette.text01
},
subtitleMessageTimestamp: {
...BaseTheme.typography.labelRegular,
color: BaseTheme.palette.text03,
marginTop: BaseTheme.spacing[1]
},
subtitleMessageInterim: {
opacity: 0.7
},
subtitlesGroupContainer: {
flexDirection: 'row',
marginBottom: BaseTheme.spacing[3]
},
subtitlesGroupAvatar: {
marginBottom: BaseTheme.spacing[10],
marginRight: BaseTheme.spacing[2],
alignSelf: 'flex-start',
width: 32
},
subtitlesGroupMessagesContainer: {
flexDirection: 'column',
flex: 1,
maxWidth: '100%'
},
subtitlesMessagesContainer: {
flex: 1,
position: 'relative',
height: '100%'
},
subtitlesMessagesList: {
padding: BaseTheme.spacing[4]
},
newMessagesButtonContainer: {
position: 'absolute',
bottom: BaseTheme.spacing[3],
alignSelf: 'center'
},
messagesContainer: {
display: 'flex',
flex: 1,
overflow: 'hidden'
}
};

View File

@@ -0,0 +1,720 @@
import { throttle } from 'lodash-es';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { connect, useSelector } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { IReduxState } from '../../../app/types';
import { isTouchDevice, shouldEnableResize } from '../../../base/environment/utils';
import { translate } from '../../../base/i18n/functions';
import { IconInfo, IconMessage, IconShareDoc, IconSubtitles } from '../../../base/icons/svg';
import { getLocalParticipant, getRemoteParticipants, isPrivateChatEnabledSelf } from '../../../base/participants/functions';
import Select from '../../../base/ui/components/web/Select';
import Tabs from '../../../base/ui/components/web/Tabs';
import { arePollsDisabled } from '../../../conference/functions.any';
import FileSharing from '../../../file-sharing/components/web/FileSharing';
import { isFileSharingEnabled } from '../../../file-sharing/functions.any';
import PollsPane from '../../../polls/components/web/PollsPane';
import { isCCTabEnabled } from '../../../subtitles/functions.any';
import {
sendMessage,
sendMessageEdit,
setChatIsResizing,
setFocusedTab,
setPrivateMessageRecipient,
setPrivateMessageRecipientById,
setUserChatWidth,
toggleChat
} from '../../actions.web';
import {
CHAT_DRAG_HANDLE_HEIGHT,
CHAT_DRAG_HANDLE_OFFSET,
CHAT_DRAG_HANDLE_WIDTH,
CHAT_SIZE,
CHAT_TOUCH_HANDLE_SIZE,
ChatTabs,
OPTION_GROUPCHAT,
SMALL_WIDTH_THRESHOLD
} from '../../constants';
import { getChatMaxSize, getFocusedTab, isChatDisabled } from '../../functions';
import { IChatProps as AbstractProps, IMessage } from '../../types';
import ChatHeader from './ChatHeader';
import ChatInput from './ChatInput';
import ClosedCaptionsTab from './ClosedCaptionsTab';
import DisplayNameForm from './DisplayNameForm';
import KeyboardAvoider from './KeyboardAvoider';
import MessageContainer from './MessageContainer';
import MessageRecipient from './MessageRecipient';
interface IProps extends AbstractProps {
/**
* The currently focused tab.
*/
_focusedTab?: ChatTabs;
/**
* True if the CC tab is enabled and false otherwise.
*/
_isCCTabEnabled: boolean;
/**
* True if chat is disabled.
*/
_isChatDisabled: boolean;
/**
* True if file sharing tab is enabled.
*/
_isFileSharingTabEnabled: boolean;
/**
* Whether the chat is opened in a modal or not (computed based on window width).
*/
_isModal: boolean;
/**
* True if the chat window should be rendered.
*/
_isOpen: boolean;
/**
* True if the polls feature is enabled.
*/
_isPollsEnabled: boolean;
/**
* Whether the user is currently resizing the chat panel.
*/
_isResizing: boolean;
/**
* The indicator which determines whether the UI is reduced.
*/
_reducedUI: boolean;
/**
* Whether or not to block chat access with a nickname input form.
*/
_showNamePrompt: boolean;
/**
* Number of unread file sharing messages.
*/
_unreadFilesCount: number;
/**
* Number of unread poll messages.
*/
_unreadPollsCount: number;
/**
* The current width of the chat panel.
*/
_width: number;
}
const useStyles = makeStyles<{
_isResizing: boolean;
isTouch: boolean;
resizeEnabled: boolean;
width: number;
}>()((theme, { _isResizing, isTouch, resizeEnabled, width }) => {
return {
container: {
backgroundColor: theme.palette.chatBackground,
flexShrink: 0,
overflow: 'hidden',
position: 'relative',
transition: _isResizing ? undefined : 'width .16s ease-in-out',
width: `${width}px`,
height: '100%',
minHeight: 0,
zIndex: 300,
// On non-touch devices (desktop), show handle on hover
// On touch devices, handle is always visible if resize is enabled
...(!isTouch && {
'&:hover, &:focus-within': {
'& .dragHandleContainer': {
visibility: 'visible'
}
}
}),
'@media (max-width: 580px)': {
height: '100dvh',
position: 'fixed',
left: 0,
right: 0,
top: 0,
width: 'auto'
},
'*': {
userSelect: 'text',
WebkitUserSelect: 'text'
}
},
chatHeader: {
height: '60px',
position: 'relative',
width: '100%',
zIndex: 1,
display: 'flex',
justifyContent: 'space-between',
padding: `${theme.spacing(3)} ${theme.spacing(4)}`,
alignItems: 'center',
boxSizing: 'border-box',
color: theme.palette.chatMessageText,
...theme.typography.heading6,
lineHeight: 'unset',
fontWeight: theme.typography.heading6.fontWeight as any,
'.jitsi-icon': {
cursor: 'pointer'
}
},
chatPanel: {
display: 'flex',
flexDirection: 'column',
minHeight: 0,
// extract header + tabs height
height: 'calc(100% - 110px)'
},
chatPanelNoTabs: {
// extract header height
height: 'calc(100% - 60px)',
minHeight: 0
},
pollsPanel: {
// extract header + tabs height
height: 'calc(100% - 110px)'
},
resizableChat: {
flex: 1,
display: 'flex',
flexDirection: 'column',
width: '100%'
},
dragHandleContainer: {
height: '100%',
// Touch devices need larger hit target but positioned to not take extra space
width: isTouch ? `${CHAT_TOUCH_HANDLE_SIZE}px` : `${CHAT_DRAG_HANDLE_WIDTH}px`,
backgroundColor: 'transparent',
position: 'absolute',
cursor: 'col-resize',
display: resizeEnabled ? 'flex' : 'none', // Hide if resize not enabled
alignItems: 'center',
justifyContent: 'center',
// On touch devices, always visible if resize enabled. On desktop, hidden by default
visibility: (isTouch && resizeEnabled) ? 'visible' : 'hidden',
// Position touch handle centered on offset from edge, maintaining same gap as non-touch
right: isTouch
? `${CHAT_DRAG_HANDLE_OFFSET - Math.floor((CHAT_TOUCH_HANDLE_SIZE - CHAT_DRAG_HANDLE_WIDTH) / 2)}px`
: `${CHAT_DRAG_HANDLE_OFFSET}px`,
top: 0,
// Prevent touch scrolling while dragging
touchAction: 'none',
'&:hover': {
'& .dragHandle': {
backgroundColor: theme.palette.icon01
}
},
'&.visible': {
visibility: 'visible',
'& .dragHandle': {
backgroundColor: theme.palette.icon01
}
}
},
dragHandle: {
// Keep the same visual appearance on all devices
backgroundColor: theme.palette.icon02,
height: `${CHAT_DRAG_HANDLE_HEIGHT}px`,
width: `${CHAT_DRAG_HANDLE_WIDTH / 3}px`,
borderRadius: '1px',
// Make more visible when actively shown
...(isTouch && resizeEnabled && {
backgroundColor: theme.palette.icon01
})
},
privateMessageRecipientsList: {
padding: '0 16px 5px'
}
};
});
const Chat = ({
_isModal,
_isOpen,
_isPollsEnabled,
_isCCTabEnabled,
_isChatDisabled,
_isFileSharingTabEnabled,
_focusedTab,
_isResizing,
_messages,
_reducedUI,
_unreadMessagesCount,
_unreadPollsCount,
_unreadFilesCount,
_showNamePrompt,
_width,
dispatch,
t
}: IProps) => {
// If no tabs are available, don't render the chat panel at all.
if (_isChatDisabled && !_isPollsEnabled && !_isCCTabEnabled && !_isFileSharingTabEnabled) {
return null;
}
// Detect touch capability and screen size for resize functionality
const isTouch = isTouchDevice();
const resizeEnabled = shouldEnableResize();
const { classes, cx } = useStyles({ _isResizing, width: _width, isTouch, resizeEnabled });
const [ isMouseDown, setIsMouseDown ] = useState(false);
const [ mousePosition, setMousePosition ] = useState<number | null>(null);
const [ dragChatWidth, setDragChatWidth ] = useState<number | null>(null);
const [ editingMessage, setEditingMessage ] = useState<IMessage | undefined>();
const maxChatWidth = useSelector(getChatMaxSize);
const notifyTimestamp = useSelector((state: IReduxState) =>
state['features/chat'].notifyPrivateRecipientsChangedTimestamp
);
const {
defaultRemoteDisplayName = 'Fellow Jitster'
} = useSelector((state: IReduxState) => state['features/base/config']);
const privateMessageRecipient = useSelector((state: IReduxState) => state['features/chat'].privateMessageRecipient);
const participants = useSelector(getRemoteParticipants);
const isPrivateChatAllowed = useSelector((state: IReduxState) => isPrivateChatEnabledSelf(state));
const options = useMemo(() => {
const o = Array.from(participants?.values() || [])
.filter(p => !p.fakeParticipant)
.map(p => {
return {
value: p.id,
label: p.name ?? defaultRemoteDisplayName
};
});
o.sort((a, b) => a.label.localeCompare(b.label));
o.unshift({
label: t('chat.everyone'),
value: OPTION_GROUPCHAT
});
return o;
}, [ participants, defaultRemoteDisplayName, t, notifyTimestamp ]);
/**
* Handles pointer down on the drag handle.
* Supports both mouse and touch events via Pointer Events API.
*
* @param {React.PointerEvent} e - The pointer down event.
* @returns {void}
*/
const onDragHandlePointerDown = useCallback((e: React.PointerEvent) => {
e.preventDefault();
e.stopPropagation();
// Capture the pointer to ensure we receive all pointer events
// even if the pointer moves outside the element
(e.target as HTMLElement).setPointerCapture(e.pointerId);
// Store the initial pointer position and chat width
setIsMouseDown(true);
setMousePosition(e.clientX);
setDragChatWidth(_width);
// Indicate that resizing is in progress
dispatch(setChatIsResizing(true));
// Add visual feedback that we're dragging (cursor for mouse, not visible on touch)
document.body.style.cursor = 'col-resize';
// Disable text selection during resize
document.body.style.userSelect = 'none';
}, [ _width, dispatch ]);
/**
* Drag handle pointer up handler.
* Supports both mouse and touch events via Pointer Events API.
*
* @returns {void}
*/
const onDragPointerUp = useCallback(() => {
if (isMouseDown) {
setIsMouseDown(false);
dispatch(setChatIsResizing(false));
// Restore cursor and text selection
document.body.style.cursor = '';
document.body.style.userSelect = '';
}
}, [ isMouseDown, dispatch ]);
const onCancelEdit = useCallback(() => {
setEditingMessage(undefined);
}, []);
/**
* Handles drag handle pointer move.
* Supports both mouse and touch events via Pointer Events API.
*
* @param {PointerEvent} e - The pointermove event.
* @returns {void}
*/
const onChatResize = useCallback(throttle((e: PointerEvent) => {
if (isMouseDown && mousePosition !== null && dragChatWidth !== null) {
// For chat panel resizing on the left edge:
// - Dragging left (decreasing X coordinate) should make the panel wider
// - Dragging right (increasing X coordinate) should make the panel narrower
const diff = e.clientX - mousePosition;
const newWidth = Math.max(
Math.min(dragChatWidth + diff, maxChatWidth),
CHAT_SIZE
);
// Update the width only if it has changed
if (newWidth !== _width) {
dispatch(setUserChatWidth(newWidth));
}
}
}, 50, {
leading: true,
trailing: false
}), [ isMouseDown, mousePosition, dragChatWidth, _width, maxChatWidth, dispatch ]);
// Set up event listeners when component mounts
useEffect(() => {
document.addEventListener('pointerup', onDragPointerUp);
document.addEventListener('pointermove', onChatResize);
return () => {
document.removeEventListener('pointerup', onDragPointerUp);
document.removeEventListener('pointermove', onChatResize);
};
}, [ onDragPointerUp, onChatResize ]);
/**
* Sends a text message.
*
* @private
* @param {string} text - The text message to be sent.
* @returns {void}
* @type {Function}
*/
const onSendMessage = useCallback((text: string) => {
if (editingMessage) {
dispatch(sendMessageEdit(editingMessage.messageId, text));
setEditingMessage(undefined);
return;
}
dispatch(sendMessage(text));
}, [ dispatch, editingMessage ]);
/**
* Toggles the chat window.
*
* @returns {Function}
*/
const onToggleChat = useCallback(() => {
dispatch(toggleChat());
}, []);
/**
* Click handler for the chat sidenav.
*
* @param {KeyboardEvent} event - Esc key click to close the popup.
* @returns {void}
*/
const onEscClick = useCallback((event: React.KeyboardEvent) => {
if (event.key === 'Escape' && _isOpen) {
event.preventDefault();
event.stopPropagation();
onToggleChat();
}
}, [ _isOpen ]);
/**
* Change selected tab.
*
* @param {string} id - Id of the clicked tab.
* @returns {void}
*/
const onChangeTab = useCallback((id: string) => {
dispatch(setFocusedTab(id as ChatTabs));
}, [ dispatch ]);
const onSelectedRecipientChange = useCallback((e: React.ChangeEvent<HTMLSelectElement>) => {
const selected = e.target.value;
if (selected === OPTION_GROUPCHAT) {
dispatch(setPrivateMessageRecipient());
} else {
dispatch(setPrivateMessageRecipientById(selected));
}
}, []);
/**
* Returns a React Element for showing chat messages and a form to send new
* chat messages.
*
* @private
* @returns {ReactElement}
*/
function renderChat() {
return (
<>
{renderTabs()}
{!_isChatDisabled && (<div
aria-labelledby = { ChatTabs.CHAT }
className = { cx(
classes.chatPanel,
!_isPollsEnabled
&& !_isCCTabEnabled
&& !_isFileSharingTabEnabled
&& classes.chatPanelNoTabs,
_focusedTab !== ChatTabs.CHAT && 'hide'
) }
id = { `${ChatTabs.CHAT}-panel` }
role = 'tabpanel'
tabIndex = { 0 }>
<MessageContainer
editingMessage = { editingMessage }
isVisible = { _focusedTab === ChatTabs.CHAT }
messages = { _messages }
onCancelEdit = { onCancelEdit }
onEditMessage = { setEditingMessage } />
<MessageRecipient />
{isPrivateChatAllowed && (
<Select
containerClassName = { cx(classes.privateMessageRecipientsList) }
id = 'select-chat-recipient'
onChange = { onSelectedRecipientChange }
options = { options }
value = { privateMessageRecipient?.id || OPTION_GROUPCHAT } />
)}
<ChatInput
editingMessage = { editingMessage }
onCancelEdit = { onCancelEdit }
onSend = { onSendMessage } />
</div>) }
{ _isPollsEnabled && (
<>
<div
aria-labelledby = { ChatTabs.POLLS }
className = { cx(classes.pollsPanel, _focusedTab !== ChatTabs.POLLS && 'hide') }
id = { `${ChatTabs.POLLS}-panel` }
role = 'tabpanel'
tabIndex = { 1 }>
<PollsPane />
</div>
<KeyboardAvoider />
</>
)}
{ _isCCTabEnabled && <div
aria-labelledby = { ChatTabs.CLOSED_CAPTIONS }
className = { cx(classes.chatPanel, _focusedTab !== ChatTabs.CLOSED_CAPTIONS && 'hide') }
id = { `${ChatTabs.CLOSED_CAPTIONS}-panel` }
role = 'tabpanel'
tabIndex = { 2 }>
<ClosedCaptionsTab />
</div> }
{ _isFileSharingTabEnabled && <div
aria-labelledby = { ChatTabs.FILE_SHARING }
className = { cx(classes.chatPanel, _focusedTab !== ChatTabs.FILE_SHARING && 'hide') }
id = { `${ChatTabs.FILE_SHARING}-panel` }
role = 'tabpanel'
tabIndex = { 3 }>
<FileSharing />
</div> }
</>
);
}
/**
* Returns a React Element showing the Chat, Polls and Subtitles tabs.
*
* @private
* @returns {ReactElement}
*/
function renderTabs() {
// The only way focused tab will be undefined is when no tab is enabled. Therefore this function won't be
// executed because Chat component won't render anything. This should never happen but adding the check
// here to make TS happy (when passing the _focusedTab in the selected prop for Tabs).
if (!_focusedTab) {
return null;
}
let tabs = [];
// Only add chat tab if chat is not disabled.
if (!_isChatDisabled) {
tabs.push({
accessibilityLabel: t('chat.tabs.chat'),
countBadge:
_focusedTab !== ChatTabs.CHAT && _unreadMessagesCount > 0 ? _unreadMessagesCount : undefined,
id: ChatTabs.CHAT,
controlsId: `${ChatTabs.CHAT}-panel`,
icon: IconMessage,
title: t('chat.tabs.chat')
});
}
if (_isPollsEnabled) {
tabs.push({
accessibilityLabel: t('chat.tabs.polls'),
countBadge: _focusedTab !== ChatTabs.POLLS && _unreadPollsCount > 0 ? _unreadPollsCount : undefined,
id: ChatTabs.POLLS,
controlsId: `${ChatTabs.POLLS}-panel`,
icon: IconInfo,
title: t('chat.tabs.polls')
});
}
if (_isCCTabEnabled) {
tabs.push({
accessibilityLabel: t('chat.tabs.closedCaptions'),
countBadge: undefined,
id: ChatTabs.CLOSED_CAPTIONS,
controlsId: `${ChatTabs.CLOSED_CAPTIONS}-panel`,
icon: IconSubtitles,
title: t('chat.tabs.closedCaptions')
});
}
if (_isFileSharingTabEnabled) {
tabs.push({
accessibilityLabel: t('chat.tabs.fileSharing'),
countBadge: _focusedTab !== ChatTabs.FILE_SHARING && _unreadFilesCount > 0 ? _unreadFilesCount : undefined,
id: ChatTabs.FILE_SHARING,
controlsId: `${ChatTabs.FILE_SHARING}-panel`,
icon: IconShareDoc,
title: t('chat.tabs.fileSharing')
});
}
if (tabs.length === 1) {
tabs = [];
}
return (
<Tabs
accessibilityLabel = { _isPollsEnabled || _isCCTabEnabled || _isFileSharingTabEnabled
? t('chat.titleWithFeatures', {
features: [
_isPollsEnabled ? t('chat.titleWithPolls') : '',
_isCCTabEnabled ? t('chat.titleWithCC') : '',
_isFileSharingTabEnabled ? t('chat.titleWithFileSharing') : ''
].filter(Boolean).join(', ')
})
: t('chat.title')
}
onChange = { onChangeTab }
selected = { _focusedTab }
tabs = { tabs } />
);
}
if (_reducedUI) {
return null;
}
return (
_isOpen ? <div
className = { classes.container }
id = 'sideToolbarContainer'
onKeyDown = { onEscClick } >
<ChatHeader
className = { cx('chat-header', classes.chatHeader) }
isCCTabEnabled = { _isCCTabEnabled }
isPollsEnabled = { _isPollsEnabled }
onCancel = { onToggleChat } />
{_showNamePrompt
? <DisplayNameForm
isCCTabEnabled = { _isCCTabEnabled }
isChatDisabled = { _isChatDisabled }
isFileSharingEnabled = { _isFileSharingTabEnabled }
isPollsEnabled = { _isPollsEnabled } />
: renderChat()}
<div
className = { cx(
classes.dragHandleContainer,
(isMouseDown || _isResizing) && 'visible',
'dragHandleContainer'
) }
onPointerDown = { onDragHandlePointerDown }>
<div className = { cx(classes.dragHandle, 'dragHandle') } />
</div>
</div> : null
);
};
/**
* Maps (parts of) the redux state to {@link Chat} React {@code Component}
* props.
*
* @param {Object} state - The redux store/state.
* @param {any} _ownProps - Components' own props.
* @private
* @returns {{
* _isModal: boolean,
* _isOpen: boolean,
* _isPollsEnabled: boolean,
* _isCCTabEnabled: boolean,
* _focusedTab: string,
* _messages: Array<Object>,
* _unreadMessagesCount: number,
* _unreadPollsCount: number,
* _unreadFilesCount: number,
* _showNamePrompt: boolean,
* _width: number,
* _isResizing: boolean
* }}
*/
function _mapStateToProps(state: IReduxState, _ownProps: any) {
const { isOpen, messages, unreadMessagesCount, unreadFilesCount, width, isResizing } = state['features/chat'];
const { unreadPollsCount } = state['features/polls'];
const _localParticipant = getLocalParticipant(state);
const { reducedUI } = state['features/base/responsive-ui'];
return {
_isModal: window.innerWidth <= SMALL_WIDTH_THRESHOLD,
_isOpen: isOpen,
_isPollsEnabled: !arePollsDisabled(state),
_isCCTabEnabled: isCCTabEnabled(state),
_isChatDisabled: isChatDisabled(state),
_isFileSharingTabEnabled: isFileSharingEnabled(state),
_focusedTab: getFocusedTab(state),
_messages: messages,
_reducedUI: reducedUI,
_unreadMessagesCount: unreadMessagesCount,
_unreadPollsCount: unreadPollsCount,
_unreadFilesCount: unreadFilesCount,
_showNamePrompt: !_localParticipant?.name,
_width: width?.current || CHAT_SIZE,
_isResizing: isResizing
};
}
export default translate(connect(_mapStateToProps)(Chat));

View File

@@ -0,0 +1,100 @@
import React from 'react';
import { connect } from 'react-redux';
import { createToolbarEvent } from '../../../analytics/AnalyticsEvents';
import { sendAnalytics } from '../../../analytics/functions';
import { IReduxState } from '../../../app/types';
import { translate } from '../../../base/i18n/functions';
import { IconMessage } from '../../../base/icons/svg';
import AbstractButton, { IProps as AbstractButtonProps } from '../../../base/toolbox/components/AbstractButton';
import { closeOverflowMenuIfOpen } from '../../../toolbox/actions.web';
import { toggleChat } from '../../actions.web';
import { isChatDisabled } from '../../functions';
import ChatCounter from './ChatCounter';
/**
* The type of the React {@code Component} props of {@link ChatButton}.
*/
interface IProps extends AbstractButtonProps {
/**
* Whether or not the chat feature is currently displayed.
*/
_chatOpen: boolean;
}
/**
* Implementation of a button for accessing chat pane.
*/
class ChatButton extends AbstractButton<IProps> {
override accessibilityLabel = 'toolbar.accessibilityLabel.openChat';
override toggledAccessibilityLabel = 'toolbar.accessibilityLabel.closeChat';
override icon = IconMessage;
override label = 'toolbar.openChat';
override toggledLabel = 'toolbar.closeChat';
override tooltip = 'toolbar.openChat';
override toggledTooltip = 'toolbar.closeChat';
/**
* Indicates whether this button is in toggled state or not.
*
* @override
* @protected
* @returns {boolean}
*/
override _isToggled() {
return this.props._chatOpen;
}
/**
* Overrides AbstractButton's {@link Component#render()}.
*
* @override
* @protected
* @returns {boReact$Nodeolean}
*/
override render() {
return (
<div
className = 'toolbar-button-with-badge'
key = 'chatcontainer'>
{super.render()}
<ChatCounter />
</div>
);
}
/**
* Handles clicking the button, and toggles the chat.
*
* @private
* @returns {void}
*/
override _handleClick() {
const { dispatch } = this.props;
sendAnalytics(createToolbarEvent(
'toggle.chat',
{
enable: !this.props._chatOpen
}));
dispatch(closeOverflowMenuIfOpen());
dispatch(toggleChat());
}
}
/**
* Function that maps parts of Redux state tree into component props.
*
* @param {Object} state - Redux state.
* @returns {Object}
*/
const mapStateToProps = (state: IReduxState) => {
return {
_chatOpen: state['features/chat'].isOpen,
visible: !isChatDisabled(state)
};
};
export default translate(connect(mapStateToProps)(ChatButton));

View File

@@ -0,0 +1,74 @@
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { IReduxState } from '../../../app/types';
import { getUnreadPollCount } from '../../../polls/functions';
import { getUnreadCount, getUnreadFilesCount } from '../../functions';
/**
* The type of the React {@code Component} props of {@link ChatCounter}.
*/
interface IProps {
/**
* The value of to display as a count.
*/
_count: number;
/**
* True if the chat window should be rendered.
*/
_isOpen: boolean;
}
/**
* Implements a React {@link Component} which displays a count of the number of
* unread chat messages.
*
* @augments Component
*/
class ChatCounter extends Component<IProps> {
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
return (
<span className = 'badge-round'>
<span>
{
!this.props._isOpen
&& (this.props._count || null)
}
</span>
</span>
);
}
}
/**
* Maps (parts of) the Redux state to the associated {@code ChatCounter}'s
* props.
*
* @param {Object} state - The Redux state.
* @private
* @returns {{
* _count: number
* }}
*/
function _mapStateToProps(state: IReduxState) {
const { isOpen } = state['features/chat'];
return {
_count: getUnreadCount(state) + getUnreadPollCount(state) + getUnreadFilesCount(state),
_isOpen: isOpen
};
}
export default connect(_mapStateToProps)(ChatCounter);

View File

@@ -0,0 +1,322 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import Icon from '../../../base/icons/components/Icon';
import {
IconArrowDown,
IconArrowUp,
IconCloseCircle,
IconCloseLarge,
IconSearch
} from '../../../base/icons/svg';
import Input from '../../../base/ui/components/web/Input';
import { isFileSharingEnabled } from '../../../file-sharing/functions.any';
import {
clearChatSearch,
setChatSearchMatchIndex,
setChatSearchQuery,
toggleChat
} from '../../actions.web';
import { ChatTabs } from '../../constants';
import {
getChatSearchMatchIndex,
getChatSearchMatches,
getChatSearchQuery,
getFocusedTab,
isChatDisabled
} from '../../functions';
interface IProps {
/**
* An optional class name.
*/
className: string;
/**
* Whether CC tab is enabled or not.
*/
isCCTabEnabled: boolean;
/**
* Whether the polls feature is enabled or not.
*/
isPollsEnabled: boolean;
/**
* Function to be called when pressing the close button.
*/
onCancel: Function;
}
const useStyles = makeStyles()(theme => {
return {
searchContainer: {
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
flex: 1,
marginRight: theme.spacing(3),
backgroundColor: theme.palette.inputFieldBackground,
borderRadius: theme.shape.borderRadius,
paddingRight: theme.spacing(2),
'&:focus-within': {
boxShadow: `0px 0px 0px 2px ${theme.palette.inputFieldFocus}`
}
},
inputWrapper: {
flex: 1,
minWidth: 0
},
searchInputField: {
backgroundColor: 'transparent !important',
background: 'transparent !important',
border: '0 !important',
boxShadow: 'none !important',
paddingRight: '4px !important',
'&:focus': {
boxShadow: 'none !important'
}
},
divider: {
width: '1px',
alignSelf: 'stretch',
margin: `${theme.spacing(1)}px ${theme.spacing(1)}px`,
backgroundColor: 'rgba(255, 255, 255, 0.15)'
},
resultsCount: {
...theme.typography.labelRegular,
color: theme.palette.text02,
whiteSpace: 'nowrap',
minWidth: '48px',
textAlign: 'center' as const
},
navButton: {
cursor: 'pointer',
opacity: 1,
'&.disabled': {
cursor: 'default',
opacity: 0.3
}
},
headerActions: {
display: 'flex',
alignItems: 'center',
gap: theme.spacing(2)
}
};
});
/**
* Custom header of the {@code ChatDialog}.
*
* @returns {React$Element<any>}
*/
function ChatHeader({ className, isCCTabEnabled, isPollsEnabled, onCancel: onCancelProp }: IProps) {
const { classes, cx } = useStyles();
const dispatch = useDispatch();
const { t } = useTranslation();
const _isChatDisabled = useSelector(isChatDisabled);
const focusedTab = useSelector(getFocusedTab);
const fileSharingTabEnabled = useSelector(isFileSharingEnabled);
const [ isSearchOpen, setIsSearchOpen ] = useState(false);
const query = useSelector(getChatSearchQuery);
const matches = useSelector(getChatSearchMatches);
const matchIndex = useSelector(getChatSearchMatchIndex);
const hasMatches = matches.length > 0;
// Local, un-debounced copy of the input value so typing feels instant, while the
// Redux dispatch (which drives the expensive getChatSearchMatches filter) is debounced.
const [ inputValue, setInputValue ] = useState(query);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
// Keep the local input in sync when the query is cleared/reset from elsewhere
// (e.g. clearChatSearch dispatched on tab switch or panel close).
useEffect(() => {
setInputValue(query);
}, [ query ]);
const onCancel = useCallback(() => {
if (onCancelProp) {
onCancelProp();
} else {
dispatch(toggleChat());
}
}, [ onCancelProp ]);
const onKeyPressHandler = useCallback(e => {
if (onCancel && (e.key === ' ' || e.key === 'Enter')) {
e.preventDefault();
onCancel();
}
}, []);
const onSearchToggle = useCallback(() => {
setIsSearchOpen(open => !open);
}, []);
const onSearchClose = useCallback(() => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
dispatch(clearChatSearch());
setIsSearchOpen(false);
}, [ dispatch ]);
const onQueryChange = useCallback((value: string) => {
setInputValue(value);
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(() => {
dispatch(setChatSearchQuery(value));
}, 200);
}, [ dispatch ]);
const onNext = useCallback(() => {
if (!hasMatches) {
return;
}
dispatch(setChatSearchMatchIndex((matchIndex + 1) % matches.length));
}, [ dispatch, hasMatches, matchIndex, matches.length ]);
const onPrev = useCallback(() => {
if (!hasMatches) {
return;
}
dispatch(setChatSearchMatchIndex((matchIndex - 1 + matches.length) % matches.length));
}, [ dispatch, hasMatches, matchIndex, matches.length ]);
const onSearchKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
e.shiftKey ? onPrev() : onNext();
} else if (e.key === 'Escape') {
onSearchClose();
}
}, [ onNext, onPrev, onSearchClose ]);
// Clear any pending debounced dispatch on unmount so it doesn't fire after teardown.
useEffect(() => {
return () => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
};
}, []);
// Keep matchIndex in bounds whenever the match set shrinks (new query, or a message
// disappearing) so `matches[matchIndex]` never reads undefined downstream.
useEffect(() => {
if (matchIndex >= matches.length && matches.length > 0) {
dispatch(setChatSearchMatchIndex(0));
}
}, [ matches.length, matchIndex, dispatch ]);
// Close the search bar if the user switches away from the Chat tab (e.g. to Polls/CC/
// File sharing) without closing the whole panel — search only makes sense on the Chat tab.
useEffect(() => {
if (isSearchOpen && focusedTab !== ChatTabs.CHAT) {
setIsSearchOpen(false);
dispatch(clearChatSearch());
}
}, [ focusedTab ]);
let title = 'chat.title';
if (!_isChatDisabled && focusedTab === ChatTabs.CHAT) {
title = 'chat.tabs.chat';
} else if (isPollsEnabled && focusedTab === ChatTabs.POLLS) {
title = 'chat.tabs.polls';
} else if (isCCTabEnabled && focusedTab === ChatTabs.CLOSED_CAPTIONS) {
title = 'chat.tabs.closedCaptions';
} else if (fileSharingTabEnabled && focusedTab === ChatTabs.FILE_SHARING) {
title = 'chat.tabs.fileSharing';
} else {
// If the focused tab is not enabled, don't render the header.
// This should not happen in normal circumstances since Chat.tsx already checks
// if any tabs are available before rendering.
return null;
}
// Only show the search toggle on the actual chat message tab — searching
// polls/CC/file-sharing isn't in scope here.
const showSearchToggle = !_isChatDisabled && focusedTab === ChatTabs.CHAT;
return (
<div
className = { className || 'chat-dialog-header' }>
{isSearchOpen ? (
<div
className = { classes.searchContainer }
onKeyDown = { onSearchKeyDown }>
<div className = { classes.inputWrapper }>
<Input
autoFocus = { true }
icon = { IconSearch }
id = 'chat-search-input'
inputClassName = { classes.searchInputField }
onChange = { onQueryChange }
placeholder = { t('chat.search.placeholder') }
value = { inputValue } />
</div>
<span className = { classes.resultsCount }>
{query.trim()
? t('chat.search.resultsCount', {
current: hasMatches ? matchIndex + 1 : 0,
total: matches.length
})
: null}
</span>
<Icon
ariaLabel = { t('chat.search.previous') }
className = { cx(classes.navButton, !hasMatches && 'disabled') }
onClick = { onPrev }
src = { IconArrowUp } />
<Icon
ariaLabel = { t('chat.search.next') }
className = { cx(classes.navButton, !hasMatches && 'disabled') }
onClick = { onNext }
src = { IconArrowDown } />
<span className = { classes.divider } />
<Icon
ariaLabel = { t('chat.search.close') }
onClick = { onSearchClose }
size = { 18 }
src = { IconCloseCircle } />
</div>
) : (
<span
aria-level = { 1 }
role = 'heading'>
{ t(title) }
</span>
)}
<div className = { classes.headerActions }>
{!isSearchOpen && showSearchToggle && (
<Icon
ariaLabel = { t('chat.search.open') }
onClick = { onSearchToggle }
role = 'button'
src = { IconSearch }
tabIndex = { 0 } />
)}
<Icon
ariaLabel = { t('toolbar.closeChat') }
onClick = { onCancel }
onKeyPress = { onKeyPressHandler }
role = 'button'
src = { IconCloseLarge }
tabIndex = { 0 } />
</div>
</div>
);
}
export default ChatHeader;

View File

@@ -0,0 +1,383 @@
import { Theme } from '@mui/material';
import React, { Component, RefObject } from 'react';
import { WithTranslation } from 'react-i18next';
import { connect } from 'react-redux';
import { withStyles } from 'tss-react/mui';
import { IReduxState, IStore } from '../../../app/types';
import { isMobileBrowser } from '../../../base/environment/utils';
import { translate } from '../../../base/i18n/functions';
import { IconFaceSmile, IconSend } from '../../../base/icons/svg';
import Button from '../../../base/ui/components/web/Button';
import Input from '../../../base/ui/components/web/Input';
import { CHAR_LIMIT, CHAT_SIZE } from '../../constants';
import { areSmileysDisabled, isSendGroupChatDisabled } from '../../functions';
import { IMessage } from '../../types';
import SmileysPanel from './SmileysPanel';
const styles = (_theme: Theme, { _chatWidth }: IProps) => {
return {
smileysPanel: {
bottom: '100%',
boxSizing: 'border-box' as const,
backgroundColor: 'rgba(0, 0, 0, .6) !important',
height: 'auto',
display: 'flex' as const,
overflow: 'hidden',
position: 'absolute' as const,
width: `${_chatWidth - 32}px`,
marginBottom: '5px',
marginLeft: '-5px',
transition: 'max-height 0.3s',
'& #smileysContainer': {
backgroundColor: '#131519',
borderTop: '1px solid #A4B8D1'
}
},
chatDisabled: {
borderTop: `1px solid ${_theme.palette.chatInputBorder}`,
boxSizing: 'border-box' as const,
padding: _theme.spacing(4),
textAlign: 'center' as const,
}
};
};
/**
* The type of the React {@code Component} props of {@link ChatInput}.
*/
interface IProps extends WithTranslation {
/**
* Whether chat emoticons are disabled.
*/
_areSmileysDisabled: boolean;
_chatWidth: number;
/**
* Whether sending group chat messages is disabled.
*/
_isSendGroupChatDisabled: boolean;
/**
* The id of the message recipient, if any.
*/
_privateMessageRecipientId?: string;
/**
* An object containing the CSS classes.
*/
classes?: Partial<Record<keyof ReturnType<typeof styles>, string>>;
/**
* Invoked to send chat messages.
*/
dispatch: IStore['dispatch'];
/**
* The message currently being edited, if any.
*/
editingMessage?: IMessage;
/**
* Callback invoked to cancel message editing.
*/
onCancelEdit?: () => void;
/**
* Callback to invoke on message send.
*/
onSend: Function;
}
/**
* The type of the React {@code Component} state of {@link ChatInput}.
*/
interface IState {
/**
* User provided nickname when the input text is provided in the view.
*/
message: string;
/**
* Whether or not the smiley selector is visible.
*/
showSmileysPanel: boolean;
}
/**
* Implements a React Component for drafting and submitting a chat message.
*
* @augments Component
*/
class ChatInput extends Component<IProps, IState> {
_textArea?: RefObject<HTMLTextAreaElement>;
override state = {
message: '',
showSmileysPanel: false
};
/**
* Initializes a new {@code ChatInput} instance.
*
* @param {Object} props - The read-only properties with which the new
* instance is to be initialized.
*/
constructor(props: IProps) {
super(props);
this._textArea = React.createRef<HTMLTextAreaElement>();
// Bind event handlers so they are only bound once for every instance.
this._onDetectSubmit = this._onDetectSubmit.bind(this);
this._onMessageChange = this._onMessageChange.bind(this);
this._onSmileySelect = this._onSmileySelect.bind(this);
this._onSubmitMessage = this._onSubmitMessage.bind(this);
this._toggleSmileysPanel = this._toggleSmileysPanel.bind(this);
}
/**
* Implements React's {@link Component#componentDidMount()}.
*
* @inheritdoc
*/
override componentDidMount() {
if (isMobileBrowser()) {
// Ensure textarea is not focused when opening chat on mobile browser.
this._textArea?.current && this._textArea.current.blur();
} else {
this._focus();
}
}
/**
* Implements {@code Component#componentDidUpdate}.
*
* @inheritdoc
*/
override componentDidUpdate(prevProps: Readonly<IProps>) {
if (prevProps._privateMessageRecipientId !== this.props._privateMessageRecipientId) {
this._textArea?.current?.focus();
}
if (prevProps.editingMessage?.messageId !== this.props.editingMessage?.messageId) {
this.setState({
message: this.props.editingMessage?.message ?? ''
});
this._focus();
}
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
const classes = withStyles.getClasses(this.props);
const hideInput = this.props._isSendGroupChatDisabled && !this.props._privateMessageRecipientId;
if (hideInput) {
return (
<div className = { classes.chatDisabled }>
{this.props.t('chat.disabled')}
</div>
);
}
return (
<div className = { `chat-input-container${this.state.message.trim().length ? ' populated' : ''}` }>
<div id = 'chat-input' >
{!this.props._areSmileysDisabled && this.state.showSmileysPanel && (
<div
className = 'smiley-input'>
<div
className = { classes.smileysPanel } >
<SmileysPanel
onSmileySelect = { this._onSmileySelect } />
</div>
</div>
)}
<Input
className = 'chat-input'
icon = { this.props._areSmileysDisabled ? undefined : IconFaceSmile }
iconClick = { this._toggleSmileysPanel }
id = 'chat-input-messagebox'
maxRows = { 5 }
onChange = { this._onMessageChange }
onKeyPress = { this._onDetectSubmit }
placeholder = { this.props.t('chat.messagebox') }
ref = { this._textArea }
textarea = { true }
value = { this.state.message } />
<Button
accessibilityLabel = { this.props.t('chat.sendButton') }
disabled = { !this.state.message.trim() }
icon = { IconSend }
onClick = { this._onSubmitMessage }
size = { isMobileBrowser() ? 'large' : 'medium' } />
</div>
</div>
);
}
/**
* Place cursor focus on this component's text area.
*
* @private
* @returns {void}
*/
_focus() {
this._textArea?.current && this._textArea.current.focus();
}
/**
* Submits the message to the chat window.
*
* @returns {void}
*/
_onSubmitMessage() {
const {
_isSendGroupChatDisabled,
_privateMessageRecipientId,
onSend
} = this.props;
if (_isSendGroupChatDisabled && !_privateMessageRecipientId) {
return;
}
const trimmed = this.state.message.trim().slice(0, CHAR_LIMIT);
if (trimmed) {
onSend(trimmed);
this.setState({ message: '' });
// Keep the textarea in focus when sending messages via submit button.
this._focus();
// Hide the Emojis box after submitting the message
this.setState({ showSmileysPanel: false });
}
}
/**
* Detects if enter has been pressed. If so, submit the message in the chat
* window.
*
* @param {string} event - Keyboard event.
* @private
* @returns {void}
*/
_onDetectSubmit(event: any) {
// Composition events used to add accents to characters
// despite their absence from standard US keyboards,
// to build up logograms of many Asian languages
// from their base components or categories and so on.
if (event.isComposing || event.keyCode === 229) {
// keyCode 229 means that user pressed some button,
// but input method is still processing that.
// This is a standard behavior for some input methods
// like entering japanese or сhinese hieroglyphs.
return;
}
if (event.key === 'Escape' && this.props.editingMessage) {
event.preventDefault();
event.stopPropagation();
this.props.onCancelEdit?.();
this.setState({ message: '' });
return;
}
if (event.key === 'Enter'
&& event.shiftKey === false
&& event.ctrlKey === false) {
event.preventDefault();
event.stopPropagation();
this._onSubmitMessage();
}
}
/**
* Updates the known message the user is drafting.
*
* @param {string} value - Keyboard event.
* @private
* @returns {void}
*/
_onMessageChange(value: string) {
this.setState({ message: value.slice(0, CHAR_LIMIT) });
}
/**
* Appends a selected smileys to the chat message draft.
*
* @param {string} smileyText - The value of the smiley to append to the
* chat message.
* @private
* @returns {void}
*/
_onSmileySelect(smileyText: string) {
if (smileyText) {
this.setState({
message: `${this.state.message} ${smileyText}`,
showSmileysPanel: false
});
} else {
this.setState({
showSmileysPanel: false
});
}
this._focus();
}
/**
* Callback invoked to hide or show the smileys selector.
*
* @private
* @returns {void}
*/
_toggleSmileysPanel() {
if (this.state.showSmileysPanel) {
this._focus();
}
this.setState({ showSmileysPanel: !this.state.showSmileysPanel });
}
}
/**
* Function that maps parts of Redux state tree into component props.
*
* @param {Object} state - Redux state.
* @private
* @returns {{
* _areSmileysDisabled: boolean
* }}
*/
const mapStateToProps = (state: IReduxState) => {
const { privateMessageRecipient, width } = state['features/chat'];
const isGroupChatDisabled = isSendGroupChatDisabled(state);
return {
_areSmileysDisabled: areSmileysDisabled(state),
_privateMessageRecipientId: privateMessageRecipient?.id,
_isSendGroupChatDisabled: isGroupChatDisabled,
_chatWidth: width.current ?? CHAT_SIZE,
};
};
export default translate(connect(mapStateToProps)(withStyles(ChatInput, styles)));

View File

@@ -0,0 +1,628 @@
import { Theme } from '@mui/material';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { connect } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { IReduxState } from '../../../app/types';
import { translate } from '../../../base/i18n/functions';
import { getParticipantById, getParticipantDisplayName, isPrivateChatEnabled } from '../../../base/participants/functions';
import Popover from '../../../base/popover/components/Popover.web';
import Message from '../../../base/react/components/web/Message';
import { MESSAGE_TYPE_LOCAL } from '../../constants';
// AFTER
import { getActiveChatSearchMatch, getChatSearchQuery, getDisplayNameSuffix, getFormattedTimestamp, getMessageText, getPrivateNoticeMessage, isFileMessage } from '../../functions';
import { IChatMessageProps } from '../../types';
import FileMessage from './FileMessage';
import MessageMenu from './MessageMenu';
import ReactButton from './ReactButton';
interface IProps extends IChatMessageProps {
className?: string;
enablePrivateChat?: boolean;
isActiveSearchMatch?: boolean;
isEditing?: boolean;
onCancelEdit?: () => void;
onEditMessage?: (message: IChatMessageProps['message']) => void;
searchQuery?: string;
shouldDisplayMenuOnRight?: boolean;
state?: IReduxState;
}
const useStyles = makeStyles()((theme: Theme) => {
return {
chatMessageFooter: {
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: theme.spacing(1)
},
chatMessageFooterLeft: {
display: 'flex',
flexGrow: 1,
overflow: 'hidden'
},
chatMessageWrapper: {
maxWidth: '100%'
},
chatMessage: {
display: 'inline-flex',
padding: '12px',
backgroundColor: theme.palette.chatMessageRemote,
borderRadius: '4px 12px 12px 12px',
maxWidth: '100%',
marginTop: '4px',
boxSizing: 'border-box' as const,
'&.file': {
display: 'flex',
maxWidth: '100%',
minWidth: 0,
'& $replyWrapper': {
width: '100%',
minWidth: 0
},
'& $messageContent': {
width: '100%',
minWidth: 0
}
},
'&.privatemessage': {
backgroundColor: theme.palette.chatMessagePrivate
},
'&.local': {
backgroundColor: theme.palette.chatMessageLocal,
borderRadius: '12px 4px 12px 12px',
'&.privatemessage': {
backgroundColor: theme.palette.chatMessagePrivate
},
'&.local': {
backgroundColor: theme.palette.chatMessageLocal,
borderRadius: '12px 4px 12px 12px',
'&.privatemessage': {
backgroundColor: theme.palette.chatMessagePrivate
}
},
'&.error': {
backgroundColor: theme.palette.actionDanger,
borderRadius: 0,
fontWeight: 100
},
'&.lobbymessage': {
backgroundColor: theme.palette.chatMessagePrivate
}
},
'&.error': {
backgroundColor: theme.palette.actionDanger,
borderRadius: 0,
fontWeight: 100
},
'&.lobbymessage': {
backgroundColor: theme.palette.chatMessagePrivate
}
},
searchMatchActive: {
outline: `2px solid ${theme.palette.action01}`,
outlineOffset: '2px',
animation: '$searchMatchFlash 1.4s ease-out'
},
'@keyframes searchMatchFlash': {
'0%': {
backgroundColor: theme.palette.warning01
},
'100%': {
backgroundColor: 'transparent'
}
},
sideBySideContainer: {
display: 'flex',
flexDirection: 'row',
justifyContent: 'left',
alignItems: 'center',
marginLeft: theme.spacing(1)
},
reactionBox: {
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
backgroundColor: theme.palette.grey[800],
borderRadius: theme.shape.borderRadius,
padding: theme.spacing(0, 1),
cursor: 'pointer'
},
reactionCount: {
fontSize: '0.8rem',
color: theme.palette.grey[400]
},
replyButton: {
padding: '2px'
},
replyWrapper: {
display: 'flex',
flexDirection: 'row' as const,
alignItems: 'center',
maxWidth: '100%'
},
messageContent: {
maxWidth: '100%',
overflow: 'hidden'
},
optionsButtonContainer: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: theme.spacing(1),
minWidth: '32px',
minHeight: '32px'
},
displayName: {
...theme.typography.labelBold,
color: theme.palette.chatSenderName,
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
overflow: 'hidden',
marginBottom: theme.spacing(1),
maxWidth: '130px'
},
userMessage: {
...theme.typography.bodyShortRegular,
color: theme.palette.chatMessageText,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
'& mark': {
backgroundColor: theme.palette.warning01,
color: theme.palette.text04,
borderRadius: '2px',
padding: '0 1px'
}
},
deletedMessage: {
fontStyle: 'italic',
opacity: 0.7
},
privateMessageNotice: {
...theme.typography.labelRegular,
color: theme.palette.chatPrivateNotice,
marginTop: theme.spacing(1)
},
timestamp: {
...theme.typography.labelRegular,
color: theme.palette.chatTimestamp,
marginTop: theme.spacing(1),
marginLeft: theme.spacing(1),
whiteSpace: 'nowrap',
flexShrink: 0
},
editedLabel: {
...theme.typography.labelRegular,
color: theme.palette.chatTimestamp,
marginLeft: theme.spacing(1),
whiteSpace: 'nowrap'
},
reactionsPopover: {
padding: theme.spacing(2),
backgroundColor: theme.palette.chatInputBackground,
borderRadius: theme.shape.borderRadius,
maxWidth: '150px',
maxHeight: '400px',
overflowY: 'auto',
color: theme.palette.chatMessageText
},
reactionItem: {
display: 'flex',
alignItems: 'center',
marginBottom: theme.spacing(1),
gap: theme.spacing(1),
borderBottom: `1px solid ${theme.palette.common.white}`,
paddingBottom: theme.spacing(1),
'&:last-child': {
borderBottom: 'none',
paddingBottom: 0
}
},
participantList: {
marginLeft: theme.spacing(1),
fontSize: '0.8rem',
maxWidth: '120px'
},
participant: {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
},
editingNotice: {
alignItems: 'center',
borderTop: `1px solid ${theme.palette.chatInputBorder}`,
color: theme.palette.chatMessageText,
display: 'flex',
justifyContent: 'space-between',
marginTop: theme.spacing(1),
paddingTop: theme.spacing(1),
width: '100%',
...theme.typography.labelRegular
},
cancelEdit: {
cursor: 'pointer',
color: theme.palette.link01
},
};
});
const ChatMessage = ({
className = '',
isEditing,
message,
state,
showDisplayName,
shouldDisplayMenuOnRight,
isActiveSearchMatch,
searchQuery,
enablePrivateChat,
knocking,
onCancelEdit,
onEditMessage,
t
}: IProps) => {
const { classes, cx } = useStyles();
const [ isHovered, setIsHovered ] = useState(false);
const [ isReactionsOpen, setIsReactionsOpen ] = useState(false);
const messageRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isActiveSearchMatch || !messageRef.current) {
return;
}
const messageElement = messageRef.current;
const container = messageElement.closest('#chatconversation') as HTMLElement | null;
if (!container) {
return;
}
const messageRect = messageElement.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
const offset = messageRect.top
- containerRect.top
- (containerRect.height / 2)
+ (messageRect.height / 2);
container.scrollTo({
top: container.scrollTop + offset,
behavior: 'smooth'
});
}, [ isActiveSearchMatch ]);
const handleEditMessage = useCallback(() => {
onEditMessage?.(message);
}, [ message, onEditMessage ]);
const handleMouseEnter = useCallback(() => {
setIsHovered(true);
}, []);
const handleMouseLeave = useCallback(() => {
setIsHovered(false);
}, []);
const handleReactionsOpen = useCallback(() => {
setIsReactionsOpen(true);
}, []);
const handleReactionsClose = useCallback(() => {
setIsReactionsOpen(false);
}, []);
/**
* Renders the display name of the sender.
*
* @returns {React$Element<*>}
*/
function _renderDisplayName() {
const { displayName } = message;
return (
<div
aria-hidden = { true }
className = { cx('display-name', classes.displayName) }>
{`${displayName}${getDisplayNameSuffix(message)}`}
</div>
);
}
/**
* Renders the message privacy notice.
*
* @returns {React$Element<*>}
*/
function _renderPrivateNotice() {
return (
<div className = { classes.privateMessageNotice }>
{getPrivateNoticeMessage(message)}
</div>
);
}
/**
* Renders the time at which the message was sent.
*
* @returns {React$Element<*>}
*/
function _renderTimestamp() {
return (
<div className = { cx('timestamp', classes.timestamp) }>
<p>
{getFormattedTimestamp(message)}
</p>
</div>
);
}
/**
* Renders the editing notice for which message is edited.
*
* @returns {React$Element<*>}
*/
function _renderEditingNotice() {
return (
<div className = { classes.editingNotice }>
<span>{t('chat.editingMessage', 'Editing')}</span>
<span
className = { classes.cancelEdit }
onClick = { onCancelEdit }>
{t('chat.cancel', 'Cancel')}
</span>
</div>
);
}
/**
* Renders the reactions for the message.
*
* @returns {React$Element<*>}
*/
const renderReactions = useMemo(() => {
if (!message.reactions || message.reactions.size === 0) {
return null;
}
const reactionsArray = Array.from(message.reactions.entries())
.map(([ reaction, participants ]) => {
return { reaction,
participants };
})
.sort((a, b) => b.participants.size - a.participants.size);
const totalReactions = reactionsArray.reduce((sum, { participants }) => sum + participants.size, 0);
const numReactionsDisplayed = 3;
const reactionsContent = (
<div className = { classes.reactionsPopover }>
{reactionsArray.map(({ reaction, participants }) => (
<div
className = { classes.reactionItem }
key = { reaction }>
<p>
<span>{reaction}</span>
<span>{participants.size}</span>
</p>
<div className = { classes.participantList }>
{Array.from(participants).map(participantId => (
<p
className = { classes.participant }
key = { participantId }>
{state && getParticipantDisplayName(state, participantId)}
</p>
))}
</div>
</div>
))}
</div>
);
return (
<Popover
content = { reactionsContent }
onPopoverClose = { handleReactionsClose }
onPopoverOpen = { handleReactionsOpen }
position = 'top'
trigger = 'hover'
visible = { isReactionsOpen }>
<div className = { classes.reactionBox }>
{reactionsArray.slice(0, numReactionsDisplayed).map(({ reaction }, index) =>
<p key = { index }>{reaction}</p>
)}
{reactionsArray.length > numReactionsDisplayed && (
<p className = { classes.reactionCount }>
+{totalReactions - numReactionsDisplayed}
</p>
)}
</div>
</Popover>
);
}, [ message?.reactions, isHovered, isReactionsOpen ]);
const messageText = message.isDeleted
? (
message.messageType === MESSAGE_TYPE_LOCAL
? t<string>('chat.deletedMessageByMe')
: t<string>('chat.deletedMessage')
)
: message.isModerated
? t('chat.moderatedMessage')
: getMessageText(message);
const canEdit = message.messageType === MESSAGE_TYPE_LOCAL
&& !message.lobbyChat
&& !message.isReaction
&& !message.isFromVisitor
&& !isFileMessage(message);
return (
<div
className = { cx(classes.chatMessageWrapper, className) }
id = { message.messageId }
onMouseEnter = { handleMouseEnter }
onMouseLeave = { handleMouseLeave }
tabIndex = { -1 }>
<div className = { classes.sideBySideContainer }>
{!shouldDisplayMenuOnRight && (
<div className = { classes.optionsButtonContainer }>
{isHovered && !message.isModerated && <MessageMenu
canEdit = { canEdit }
displayName = { message.displayName }
enablePrivateChat = { Boolean(enablePrivateChat) }
isFileMessage = { isFileMessage(message) }
isFromVisitor = { message.isFromVisitor }
isLobbyMessage = { message.lobbyChat }
isModerated = { message.isModerated }
message = { message }
onEditMessage = { handleEditMessage }
participantId = { message.participantId } />}
</div>
)}
<div
className = { cx(
'chatmessage',
classes.chatMessage,
className,
message.privateMessage && 'privatemessage',
message.lobbyChat && !knocking && 'lobbymessage',
isFileMessage(message) && 'file',
isActiveSearchMatch && classes.searchMatchActive
) }
ref = { messageRef }>
<div className = { classes.replyWrapper }>
<div className = { cx('messagecontent', classes.messageContent) }>
{showDisplayName && _renderDisplayName()}
<div className = { cx('usermessage', classes.userMessage, message.isDeleted && message.isModerated && classes.deletedMessage) }>
{!message.isModerated && isFileMessage(message) ? (
<FileMessage
message = { message }
screenReaderHelpText = { message.messageType === MESSAGE_TYPE_LOCAL
? t<string>('chat.fileAccessibleTitleMe')
: t<string>('chat.fileAccessibleTitle', {
user: message.displayName
})
} />
) : (
<Message
highlightQuery = { searchQuery }
screenReaderHelpText = { message.messageType === MESSAGE_TYPE_LOCAL
? t<string>('chat.messageAccessibleTitleMe')
: t<string>('chat.messageAccessibleTitle', {
user: message.displayName
}) }
text = { messageText } />
)}
{message.isModerated && message.moderationReason && (
<div className = { classes.privateMessageNotice } >
{t('chat.moderationReason', {
reason: message.moderationReason
})}
</div>
)}
{(message.privateMessage || (message.lobbyChat && !knocking))
&& _renderPrivateNotice()}
<div className = { classes.chatMessageFooter }>
<div className = { classes.chatMessageFooterLeft }>
{!message.isDeleted && !message.isModerated && message.reactions && message.reactions.size > 0 && (
<>
{renderReactions}
</>
)}
</div>
{message.isEdited && (
<div className = { classes.editedLabel }>
{t('chat.edited', '(edited)')}
</div>
)}
{_renderTimestamp()}
</div>
</div>
</div>
</div>
</div>
{shouldDisplayMenuOnRight && (
<div className = { classes.sideBySideContainer }>
{!message.privateMessage && !message.lobbyChat
&& !message.isReaction && !message.isDeleted && !message.isModerated && <div>
<div className = { classes.optionsButtonContainer }>
{isHovered && <ReactButton
messageId = { message.messageId }
receiverId = { '' } />}
</div>
</div>}
<div>
<div className = { classes.optionsButtonContainer }>
{isHovered && !message.isDeleted && !message.isModerated && <MessageMenu
canEdit = { canEdit }
displayName = { message.displayName }
enablePrivateChat = { Boolean(enablePrivateChat) }
isFileMessage = { isFileMessage(message) }
isFromVisitor = { message.isFromVisitor }
isLobbyMessage = { message.lobbyChat }
isModerated = { message.isModerated }
message = { message }
onEditMessage = { handleEditMessage }
participantId = { message.participantId } />}
</div>
</div>
</div>
)}
</div>
{isEditing && _renderEditingNotice()}
</div>
);
};
/**
* Maps part of the Redux store to the props of this component.
*
* @param {Object} state - The Redux state.
* @returns {IProps}
*/
function _mapStateToProps(state: IReduxState, { message }: IProps) {
const { knocking } = state['features/lobby'];
const activeSearchMatch = getActiveChatSearchMatch(state);
const isActiveSearchMatch = Boolean(activeSearchMatch && activeSearchMatch.messageId === message.messageId);
const participant = getParticipantById(state, message.participantId);
// For visitor private messages, participant will be undefined but we should still allow private chat
// Create a visitor participant object for visitor messages to pass to isPrivateChatEnabled
const participantForCheck = message.isFromVisitor
? { id: message.participantId, name: message.displayName, isVisitor: true as const }
: participant;
const enablePrivateChat = (!message.isFromVisitor || message.privateMessage)
&& isPrivateChatEnabled(participantForCheck, state);
// Only the local messages appear on the right side of the chat therefore only for them the menu has to be on the
// left side.
const shouldDisplayMenuOnRight = message.messageType !== MESSAGE_TYPE_LOCAL;
return {
shouldDisplayMenuOnRight,
enablePrivateChat,
isActiveSearchMatch,
searchQuery: getChatSearchQuery(state),
knocking,
state
};
}
export default translate(connect(_mapStateToProps)(ChatMessage));

View File

@@ -0,0 +1,97 @@
import clsx from 'clsx';
import React from 'react';
import { makeStyles } from 'tss-react/mui';
import Avatar from '../../../base/avatar/components/Avatar';
import { IMessage } from '../../types';
import ChatMessage from './ChatMessage';
interface IProps {
/**
* Additional CSS classes to apply to the root element.
*/
className: string;
editingMessage?: IMessage;
/**
* The messages to display as a group.
*/
messages: Array<IMessage>;
onCancelEdit?: () => void;
/**
* Callback invoked when the user wants to edit a message.
*/
onEditMessage?: (message: IMessage) => void;
}
const useStyles = makeStyles()(theme => {
return {
messageGroup: {
display: 'flex',
flexDirection: 'column',
maxWidth: '100%',
'&.remote, &.file': {
maxWidth: 'calc(100% - 40px)' // 100% - avatar and margin
}
},
groupContainer: {
display: 'flex',
'&.local': {
justifyContent: 'flex-end',
'& .avatar': {
display: 'none'
}
}
},
avatar: {
margin: `${theme.spacing(1)} ${theme.spacing(2)} ${theme.spacing(3)} 0`,
position: 'sticky',
flexShrink: 0,
top: 0
}
};
});
const ChatMessageGroup = ({ className = '', editingMessage, messages, onCancelEdit, onEditMessage }: IProps) => {
const { classes } = useStyles();
const messagesLength = messages.length;
if (!messagesLength) {
return null;
}
return (
<div className = { clsx(classes.groupContainer, className) }>
<Avatar
className = { clsx(classes.avatar, 'avatar') }
participantId = { messages[0].participantId }
size = { 32 } />
<div className = { `${classes.messageGroup} chat-message-group ${className}` }>
{messages.map((message, i) => (
<ChatMessage
className = { className }
isEditing = { editingMessage?.messageId === message.messageId }
key = { i }
message = { message }
onCancelEdit = { onCancelEdit }
onEditMessage = { onEditMessage }
showDisplayName = { i === 0 }
showTimestamp = { i === messages.length - 1 } />
))}
</div>
</div>
);
};
export default ChatMessageGroup;

View File

@@ -0,0 +1,34 @@
import React from 'react';
import { connect } from 'react-redux';
import { translate } from '../../../base/i18n/functions';
import Dialog from '../../../base/ui/components/web/Dialog';
import { AbstractChatPrivacyDialog, _mapDispatchToProps, _mapStateToProps } from '../AbstractChatPrivacyDialog';
/**
* Implements a component for the dialog displayed to avoid mis-sending private messages.
*/
class ChatPrivacyDialog extends AbstractChatPrivacyDialog {
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
return (
<Dialog
cancel = {{ translationKey: 'dialog.sendPrivateMessageCancel' }}
ok = {{ translationKey: 'dialog.sendPrivateMessageOk' }}
onCancel = { this._onSendGroupMessage }
onSubmit = { this._onSendPrivateMessage }
titleKey = 'dialog.sendPrivateMessageTitle'>
<div>
{ this.props.t('dialog.sendPrivateMessage') }
</div>
</Dialog>
);
}
}
export default translate(connect(_mapStateToProps, _mapDispatchToProps)(ChatPrivacyDialog));

View File

@@ -0,0 +1,132 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import Icon from '../../../base/icons/components/Icon';
import { IconSubtitles } from '../../../base/icons/svg';
import Button from '../../../base/ui/components/web/Button';
import LanguageSelector from '../../../subtitles/components/web/LanguageSelector';
import { ChatTabs } from '../../constants';
import { getFocusedTab } from '../../functions';
// @ts-ignore
import AbstractClosedCaptions, { AbstractProps } from '../AbstractClosedCaptions';
import { SubtitlesMessagesContainer } from './SubtitlesMessagesContainer';
/**
* The styles for the ClosedCaptionsTab component.
*/
const useStyles = makeStyles()(theme => {
return {
subtitlesList: {
display: 'flex',
flexDirection: 'column',
height: '100%',
overflowY: 'auto',
padding: '16px',
flex: 1,
boxSizing: 'border-box',
color: theme.palette.chatMessageText
},
container: {
display: 'flex',
flexDirection: 'column',
height: '100%',
position: 'relative',
overflow: 'hidden'
},
messagesContainer: {
display: 'flex',
flexDirection: 'column',
flex: 1,
overflow: 'hidden'
},
emptyContent: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
padding: '16px',
boxSizing: 'border-box',
flexDirection: 'column',
gap: '16px',
color: theme.palette.chatMessageText,
textAlign: 'center'
},
emptyIcon: {
width: '100px',
padding: '16px',
'& svg': {
width: '100%',
height: 'auto'
}
},
emptyState: {
...theme.typography.bodyLongBold,
color: theme.palette.chatSenderName
}
};
});
/**
* Component that displays the subtitles history in a scrollable list.
*
* @returns {JSX.Element} - The ClosedCaptionsTab component.
*/
const ClosedCaptionsTab = ({
canStartSubtitles,
filteredSubtitles,
groupedSubtitles,
isButtonPressed,
isTranscribing,
startClosedCaptions
}: AbstractProps): JSX.Element => {
const { classes, theme } = useStyles();
const { t } = useTranslation();
const isVisible = useSelector(getFocusedTab) === ChatTabs.CLOSED_CAPTIONS;
if (!isTranscribing) {
if (canStartSubtitles) {
return (
<div className = { classes.emptyContent }>
<Button
accessibilityLabel = 'Start Closed Captions'
appearance = 'primary'
disabled = { isButtonPressed }
labelKey = 'closedCaptionsTab.startClosedCaptionsButton'
onClick = { startClosedCaptions }
size = 'large'
type = 'primary' />
</div>
);
}
return (
<div className = { classes.emptyContent }>
<Icon
className = { classes.emptyIcon }
color = { theme.palette.chatEmptyText }
src = { IconSubtitles } />
<span className = { classes.emptyState }>
{ t('closedCaptionsTab.emptyState') }
</span>
</div>
);
}
return (
<div className = { classes.container }>
<LanguageSelector />
<div className = { classes.messagesContainer }>
<SubtitlesMessagesContainer
groups = { groupedSubtitles }
isVisible = { isVisible }
messages = { filteredSubtitles } />
</div>
</div>
);
};
export default AbstractClosedCaptions(ClosedCaptionsTab);

View File

@@ -0,0 +1,180 @@
import React, { Component } from 'react';
import { WithTranslation } from 'react-i18next';
import { connect } from 'react-redux';
import { IStore } from '../../../app/types';
import { translate } from '../../../base/i18n/functions';
import { updateSettings } from '../../../base/settings/actions';
import Button from '../../../base/ui/components/web/Button';
import Input from '../../../base/ui/components/web/Input';
import KeyboardAvoider from './KeyboardAvoider';
/**
* The type of the React {@code Component} props of {@DisplayNameForm}.
*/
interface IProps extends WithTranslation {
/**
* Invoked to set the local participant display name.
*/
dispatch: IStore['dispatch'];
/**
* Whether CC tab is enabled or not.
*/
isCCTabEnabled: boolean;
/**
* Whether chat is disabled.
*/
isChatDisabled: boolean;
/**
* Whether file sharing is enabled.
*/
isFileSharingEnabled: boolean;
/**
* Whether the polls feature is enabled or not.
*/
isPollsEnabled: boolean;
}
/**
* The type of the React {@code Component} state of {@DisplayNameForm}.
*/
interface IState {
/**
* User provided display name when the input text is provided in the view.
*/
displayName: string;
}
/**
* React Component for requesting the local participant to set a display name.
*
* @augments Component
*/
class DisplayNameForm extends Component<IProps, IState> {
override state = {
displayName: ''
};
/**
* Initializes a new {@code DisplayNameForm} instance.
*
* @param {Object} props - The read-only properties with which the new
* instance is to be initialized.
*/
constructor(props: IProps) {
super(props);
// Bind event handlers so they are only bound once for every instance.
this._onDisplayNameChange = this._onDisplayNameChange.bind(this);
this._onSubmit = this._onSubmit.bind(this);
this._onKeyPress = this._onKeyPress.bind(this);
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
const { isCCTabEnabled, isChatDisabled, isFileSharingEnabled, isPollsEnabled, t } = this.props;
// Build array of enabled feature names (translated).
const features = [
!isChatDisabled ? t('chat.nickname.featureChat') : '',
isPollsEnabled ? t('chat.nickname.featurePolls') : '',
isFileSharingEnabled ? t('chat.nickname.featureFileSharing') : '',
isCCTabEnabled ? t('chat.nickname.featureClosedCaptions') : ''
].filter(Boolean);
// Return null if no features available - component won't render.
if (features.length === 0) {
return null;
}
// Build translation arguments dynamically: { feature1: "chat", feature2: "polls", ... }
const translationArgs = features.reduce((acc, feature, index) => {
acc[`feature${index + 1}`] = feature;
return acc;
}, {} as Record<string, string>);
// Use dynamic translation key: "titleWith1Features", "titleWith2Features", etc.
const title = t(`chat.nickname.titleWith${features.length}Features`, translationArgs);
return (
<div id = 'nickname'>
<form onSubmit = { this._onSubmit }>
<Input
accessibilityLabel = { t(title) }
autoFocus = { true }
id = 'nickinput'
label = { t(title) }
name = 'name'
onChange = { this._onDisplayNameChange }
placeholder = { t('chat.nickname.popover') }
type = 'text'
value = { this.state.displayName } />
</form>
<br />
<Button
accessibilityLabel = { t('chat.enter') }
disabled = { !this.state.displayName.trim() }
fullWidth = { true }
label = { t('chat.enter') }
onClick = { this._onSubmit } />
<KeyboardAvoider />
</div>
);
}
/**
* Dispatches an action update the entered display name.
*
* @param {string} value - Keyboard event.
* @private
* @returns {void}
*/
_onDisplayNameChange(value: string) {
this.setState({ displayName: value });
}
/**
* Dispatches an action to hit enter to change your display name.
*
* @param {event} event - Keyboard event
* that will check if user has pushed the enter key.
* @private
* @returns {void}
*/
_onSubmit(event: any) {
event?.preventDefault?.();
// Store display name in settings
this.props.dispatch(updateSettings({
displayName: this.state.displayName
}));
}
/**
* KeyPress handler for accessibility.
*
* @param {Object} e - The key event to handle.
*
* @returns {void}
*/
_onKeyPress(e: React.KeyboardEvent) {
if (e.key === ' ' || e.key === 'Enter') {
this._onSubmit(e);
}
}
}
export default translate(connect()(DisplayNameForm));

View File

@@ -0,0 +1,60 @@
import { Theme } from '@mui/material';
import React, { useCallback } from 'react';
import { makeStyles } from 'tss-react/mui';
interface IProps {
onSelect: (emoji: string) => void;
}
const useStyles = makeStyles()((theme: Theme) => {
return {
emojiGrid: {
display: 'flex',
flexDirection: 'row',
borderRadius: '4px',
backgroundColor: theme.palette.chatInputBackground
},
emojiButton: {
cursor: 'pointer',
padding: '5px',
fontSize: '1.5em'
}
};
});
const EmojiSelector: React.FC<IProps> = ({ onSelect }) => {
const { classes } = useStyles();
const emojiMap: Record<string, string> = {
thumbsUp: '👍',
redHeart: '❤️',
faceWithTearsOfJoy: '😂',
faceWithOpenMouth: '😮',
fire: '🔥'
};
const emojiNames = Object.keys(emojiMap);
const handleSelect = useCallback(
(emoji: string) => (event: React.MouseEvent<HTMLSpanElement>) => {
event.preventDefault();
onSelect(emoji);
},
[ onSelect ]
);
return (
<div className = { classes.emojiGrid }>
{emojiNames.map(name => (
<span
className = { classes.emojiButton }
key = { name }
onClick = { handleSelect(emojiMap[name]) }>
{emojiMap[name]}
</span>
))}
</div>
);
};
export default EmojiSelector;

View File

@@ -0,0 +1,143 @@
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { downloadFile, removeFile } from '../../../file-sharing/actions';
import FileItem from '../../../file-sharing/components/web/FileItem';
import { isFileUploadingEnabled } from '../../../file-sharing/functions.any';
import { IMessage } from '../../types';
/**
* Props for the FileMessage component.
*/
interface IFileMessageProps {
/**
* Additional CSS class name.
*/
className?: string;
/**
* The message containing file metadata.
*/
message: IMessage;
/**
* Screen reader help text for accessibility.
*/
screenReaderHelpText?: string;
}
const useStyles = makeStyles()(theme => {
return {
fileMessageContainer: {
margin: `${theme.spacing(1)} 0`,
maxWidth: '100%',
minWidth: 0,
padding: 0,
display: 'flex',
flexDirection: 'column',
// Override FileItem styles for compact chat display
'& .fileItem': {
padding: theme.spacing(2), // Reduced from 3 (24px → 16px)
gap: theme.spacing(2), // Reduced from 3
// Add background to button container to hide text underneath in chat context
'& > div:last-child': {
backgroundColor: theme.palette.chatMessageRemote,
paddingLeft: theme.spacing(2)
},
'&:hover > div:last-child': {
backgroundColor: theme.palette.chatInputBackground
}
},
'& .fileName': {
...theme.typography.bodyShortRegular // Match message text font
},
'& .fileSize': {
...theme.typography.labelRegular // Keep smaller for metadata
}
},
deletedFileMessage: {
...theme.typography.bodyShortRegular,
fontStyle: 'italic',
color: theme.palette.fileSharingEmptyText,
padding: theme.spacing(1, 0)
}
};
});
/**
* Component for displaying file messages in chat.
*
* @param {IFileMessageProps} props - The component props.
* @returns {JSX.Element | null} The FileMessage component or null if no file metadata.
*/
const FileMessage = ({ className = '', message, screenReaderHelpText }: IFileMessageProps) => {
const { classes, cx } = useStyles();
const dispatch = useDispatch();
const { t } = useTranslation();
const isUploadEnabled = useSelector(isFileUploadingEnabled);
/**
* Handles the download action for a file.
*
* @param {string} fileId - The ID of the file to download.
* @returns {void}
*/
const handleDownload = useCallback((fileId: string) => {
dispatch(downloadFile(fileId));
}, [ dispatch ]);
/**
* Handles the remove action for a file.
*
* @param {string} fileId - The ID of the file to remove.
* @returns {void}
*/
const handleRemove = useCallback((fileId: string) => {
dispatch(removeFile(fileId));
}, [ dispatch ]);
if (!message.fileMetadata) {
return null;
}
// If the file has been deleted, show a deletion message instead of the file item.
if (message.fileMetadata.isDeleted) {
return (
<div className = { cx(classes.fileMessageContainer, className) }>
<div className = { classes.deletedFileMessage }>
{t('chat.fileDeleted')}
</div>
</div>
);
}
return (
<div className = { cx(classes.fileMessageContainer, className) }>
{screenReaderHelpText && (
<span className = 'sr-only'>
{screenReaderHelpText}
</span>
)}
<FileItem
actionsVisible = { true }
className = 'fileItem'
file = { message.fileMetadata }
iconSize = { 40 }
onDownload = { handleDownload }
onRemove = { handleRemove }
showAuthor = { false }
showRemoveButton = { isUploadEnabled }
showTimestamp = { false } />
</div>
);
};
export default FileMessage;

View File

@@ -0,0 +1,40 @@
import React from 'react';
import { makeStyles } from 'tss-react/mui';
interface IProps {
/**
* URL of the GIF.
*/
url: string;
}
const useStyles = makeStyles()(() => {
return {
container: {
display: 'flex',
justifyContent: 'center',
overflow: 'hidden',
maxHeight: '150px',
'& img': {
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
flexGrow: 1
}
}
};
});
const GifMessage = ({ url }: IProps) => {
const { classes: styles } = useStyles();
return (<div className = { styles.container }>
<img
alt = { url }
src = { url } />
</div>);
};
export default GifMessage;

View File

@@ -0,0 +1,54 @@
import React, { useEffect, useState } from 'react';
import { isIosMobileBrowser } from '../../../base/environment/utils';
/**
* Component that renders an element to lift the chat input above the Safari keyboard,
* computing the appropriate height comparisons based on the {@code visualViewport}.
*
* @returns {ReactElement}
*/
function KeyboardAvoider() {
if (!isIosMobileBrowser()) {
return null;
}
const [ elementHeight, setElementHeight ] = useState(0);
const [ storedHeight, setStoredHeight ] = useState(window.innerHeight);
/**
* Handles the resizing of the visual viewport in order to compute
* the {@code KeyboardAvoider}'s height.
*
* @returns {void}
*/
function handleViewportResize() {
const { innerWidth, visualViewport } = window;
const { width, height } = visualViewport ?? {};
// Compare the widths to make sure the {@code visualViewport} didn't resize due to zooming.
if (width === innerWidth) {
if (Number(height) < storedHeight) {
setElementHeight(storedHeight - Number(height));
} else {
setElementHeight(0);
}
setStoredHeight(Number(height));
}
}
useEffect(() => {
// Call the handler in case the keyboard is open when the {@code KeyboardAvoider} is mounted.
handleViewportResize();
window.visualViewport?.addEventListener('resize', handleViewportResize);
return () => {
window.visualViewport?.removeEventListener('resize', handleViewportResize);
};
}, []);
return <div style = {{ height: `${elementHeight}px` }} />;
}
export default KeyboardAvoider;

View File

@@ -0,0 +1,378 @@
import { throttle } from 'lodash-es';
import React, { Component, RefObject } from 'react';
import { scrollIntoView } from 'seamless-scroll-polyfill';
import { groupMessagesBySender } from '../../../base/util/messageGrouping';
import { MESSAGE_TYPE_LOCAL, MESSAGE_TYPE_REMOTE } from '../../constants';
import { IMessage } from '../../types';
import ChatMessageGroup from './ChatMessageGroup';
import NewMessagesButton from './NewMessagesButton';
interface IProps {
editingMessage?: IMessage;
/**
* Whether the message container is currently visible to the user.
* Defaults to true when not provided.
*/
isVisible?: boolean;
messages: IMessage[];
onCancelEdit?: () => void;
/**
* Callback invoked when the user wants to edit a message.
*/
onEditMessage?: (message: IMessage) => void;
}
interface IState {
/**
* Whether or not message container has received new messages.
*/
hasNewMessages: boolean;
/**
* Whether or not scroll position is at the bottom of container.
*/
isScrolledToBottom: boolean;
/**
* The id of the last read message.
*/
lastReadMessageId: string | null;
}
/**
* Displays all received chat messages, grouped by sender.
*
* @augments Component
*/
export default class MessageContainer extends Component<IProps, IState> {
/**
* Component state used to decide when the hasNewMessages button to appear
* and where to scroll when click on hasNewMessages button.
*/
override state: IState = {
hasNewMessages: false,
isScrolledToBottom: true,
lastReadMessageId: ''
};
/**
* Reference to the HTML element at the end of the list of displayed chat
* messages. Used for scrolling to the end of the chat messages.
*/
_messagesListEndRef: RefObject<HTMLDivElement>;
/**
* A React ref to the HTML element containing all {@code ChatMessageGroup}
* instances.
*/
_messageListRef: RefObject<HTMLDivElement>;
/**
* Intersection observer used to detect intersections of messages with the bottom of the message container.
*/
_bottomListObserver: IntersectionObserver;
/**
* Whether the component has performed its initial scroll to the bottom.
* Used to ensure we scroll on first visibility but preserve scroll position on subsequent tab switches.
*/
_hasInitiallyScrolled: boolean;
static defaultProps = {
isVisible: true,
messages: [] as IMessage[]
};
/**
* Initializes a new {@code MessageContainer} instance.
*
* @param {IProps} props - The React {@code Component} props to initialize
* the new {@code MessageContainer} instance with.
*/
constructor(props: IProps) {
super(props);
this._messageListRef = React.createRef<HTMLDivElement>();
this._messagesListEndRef = React.createRef<HTMLDivElement>();
this._hasInitiallyScrolled = false;
// Bind event handlers so they are only bound once for every instance.
this._handleIntersectBottomList = this._handleIntersectBottomList.bind(this);
this._findFirstUnreadMessage = this._findFirstUnreadMessage.bind(this);
this._isMessageVisible = this._isMessageVisible.bind(this);
this._onChatScroll = throttle(this._onChatScroll.bind(this), 300, { leading: true });
this._onGoToFirstUnreadMessage = this._onGoToFirstUnreadMessage.bind(this);
}
/**
* Implements {@code Component#render}.
*
* @inheritdoc
*/
override render() {
const groupedMessages = this._getMessagesGroupedBySender();
const content = groupedMessages.map((group, index) => {
const { messages } = group;
const messageType = messages[0]?.messageType;
return (
<ChatMessageGroup
className = { messageType || MESSAGE_TYPE_REMOTE }
editingMessage = { this.props.editingMessage }
key = { index }
messages = { messages }
onCancelEdit = { this.props.onCancelEdit }
onEditMessage = { this.props.onEditMessage } />
);
});
return (
<div id = 'chat-conversation-container'>
<div
aria-labelledby = 'chat-header'
id = 'chatconversation'
onScroll = { this._onChatScroll }
ref = { this._messageListRef }
role = 'log'
tabIndex = { 0 }>
{ content }
{ !this.state.isScrolledToBottom && this.state.hasNewMessages
&& <NewMessagesButton
onGoToFirstUnreadMessage = { this._onGoToFirstUnreadMessage } /> }
<div
id = 'messagesListEnd'
ref = { this._messagesListEndRef } />
</div>
</div>
);
}
/**
* Implements {@code Component#componentDidMount}.
* When Component mount scroll message container to bottom.
* Create observer to react when scroll position is at bottom or leave the bottom.
*
* @inheritdoc
*/
override componentDidMount() {
// Only scroll on mount if the component is visible, since scrollIntoView
// silently does nothing on hidden (display: none) elements. If hidden,
// componentDidUpdate will handle scrolling when the component first becomes visible.
if (this.props.isVisible) {
this._hasInitiallyScrolled = true;
requestAnimationFrame(() => {
this.scrollToElement(false, null);
});
}
this._createBottomListObserver();
}
/**
* Implements {@code Component#componentDidUpdate}.
* If the user receive a new message or the local user send a new message,
* scroll automatically to the bottom if scroll position was at the bottom.
* Otherwise update hasNewMessages from component state.
*
* @inheritdoc
* @returns {void}
*/
override componentDidUpdate(prevProps: IProps) {
const newMessages = this.props.messages.filter(message => !prevProps.messages.includes(message));
const hasLocalMessage = newMessages.map(message => message.messageType).includes(MESSAGE_TYPE_LOCAL);
if (newMessages.length > 0) {
if (this.state.isScrolledToBottom || hasLocalMessage) {
this.scrollToElement(false, null);
} else {
// eslint-disable-next-line react/no-did-update-set-state
this.setState({ hasNewMessages: true });
}
}
// If the component was mounted while hidden, scrollIntoView was skipped.
// Scroll to the bottom the first time it becomes visible to show the latest messages.
if (this.props.isVisible && !prevProps.isVisible && !this._hasInitiallyScrolled) {
this._hasInitiallyScrolled = true;
requestAnimationFrame(() => {
this.scrollToElement(false, null);
});
}
}
/**
* Implements React's {@link Component#componentWillUnmount()}. Invoked
* immediately before this component is unmounted and destroyed.
*
* @inheritdoc
*/
override componentWillUnmount() {
const target = document.querySelector('#messagesListEnd');
this._bottomListObserver.unobserve(target as Element);
}
/**
* Automatically scrolls the displayed chat messages to bottom or to a specific element if it is provided.
*
* @param {boolean} withAnimation - Whether or not to show a scrolling.
* @param {TMLElement} element - Where to scroll.
* Animation.
* @returns {void}
*/
scrollToElement(withAnimation: boolean, element: Element | null) {
const scrollTo = element ? element : this._messagesListEndRef.current;
const block = element ? 'center' : 'nearest';
scrollIntoView(scrollTo as Element, {
behavior: withAnimation ? 'smooth' : 'auto',
block
});
}
/**
* Callback invoked to listen to current scroll position and update next unread message.
* The callback is invoked inside a throttle with 300 ms to decrease the number of function calls.
*
* @private
* @returns {void}
*/
_onChatScroll() {
const firstUnreadMessage = this._findFirstUnreadMessage();
if (firstUnreadMessage && firstUnreadMessage.id !== this.state.lastReadMessageId) {
this.setState({ lastReadMessageId: firstUnreadMessage?.id });
}
}
/**
* Find the first unread message.
* Update component state and scroll to element.
*
* @private
* @returns {void}
*/
_onGoToFirstUnreadMessage() {
const firstUnreadMessage = this._findFirstUnreadMessage();
this.setState({ lastReadMessageId: firstUnreadMessage?.id || null });
this.scrollToElement(true, firstUnreadMessage as Element);
}
/**
* Create observer to react when scroll position is at bottom or leave the bottom.
*
* @private
* @returns {void}
*/
_createBottomListObserver() {
const options = {
root: document.querySelector('#chatconversation'),
rootMargin: '35px',
threshold: 0.5
};
const target = document.querySelector('#messagesListEnd');
if (target) {
this._bottomListObserver = new IntersectionObserver(this._handleIntersectBottomList, options);
this._bottomListObserver.observe(target);
}
}
/** .
* _HandleIntersectBottomList.
* When entry is intersecting with bottom of container set last message as last read message.
* When entry is not intersecting update only isScrolledToBottom with false value.
*
* @param {Array} entries - List of entries.
* @private
* @returns {void}
*/
_handleIntersectBottomList(entries: IntersectionObserverEntry[]) {
entries.forEach((entry: IntersectionObserverEntry) => {
if (entry.isIntersecting && this.props.messages.length) {
const lastMessageIndex = this.props.messages.length - 1;
const lastMessage = this.props.messages[lastMessageIndex];
const lastReadMessageId = lastMessage.messageId;
this.setState(
{
isScrolledToBottom: true,
hasNewMessages: false,
lastReadMessageId
});
}
if (!entry.isIntersecting) {
this.setState(
{
isScrolledToBottom: false
});
}
});
}
/**
* Find first unread message.
* MessageIsAfterLastSeenMessage filter elements which are not visible but are before the last read message.
*
* @private
* @returns {Element}
*/
_findFirstUnreadMessage() {
const messagesNodeList = document.querySelectorAll('.chatmessage-wrapper');
// @ts-ignore
const messagesToArray = [ ...messagesNodeList ];
const previousIndex = messagesToArray.findIndex((message: Element) =>
message.id === this.state.lastReadMessageId);
if (previousIndex !== -1) {
for (let i = previousIndex; i < messagesToArray.length; i++) {
if (!this._isMessageVisible(messagesToArray[i])) {
return messagesToArray[i];
}
}
}
}
/**
* Check if a message is visible in view.
*
* @param {Element} message - The message.
*
* @returns {boolean}
*/
_isMessageVisible(message: Element): boolean {
const { bottom, height, top } = message.getBoundingClientRect();
if (this._messageListRef.current) {
const containerRect = this._messageListRef.current.getBoundingClientRect();
return top <= containerRect.top
? containerRect.top - top <= height : bottom - containerRect.bottom <= height;
}
return false;
}
/**
* Returns an array of message groups, where each group is an array of messages
* grouped by the sender.
*
* @returns {Array<Array<Object>>}
*/
_getMessagesGroupedBySender() {
return groupMessagesBySender(this.props.messages);
}
}

View File

@@ -0,0 +1,246 @@
import React, { useCallback, useRef, useState } from 'react';
import ReactDOM from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { IReduxState } from '../../../app/types';
import { IconDotsHorizontal } from '../../../base/icons/svg';
import { getParticipantById, isLocalParticipantModerator } from '../../../base/participants/functions';
import Popover from '../../../base/popover/components/Popover.web';
import Button from '../../../base/ui/components/web/Button';
import { BUTTON_TYPES } from '../../../base/ui/constants.any';
import { copyText } from '../../../base/util/copyText.web';
import { sendMessageModeration, sendMessageRetraction } from '../../actions.any';
import { handleLobbyChatInitialized, openChat } from '../../actions.web';
import { MESSAGE_TYPE_LOCAL } from '../../constants';
import logger from '../../logger';
import { IMessage } from '../../types';
export interface IProps {
canEdit?: boolean;
className?: string;
displayName?: string;
enablePrivateChat: boolean;
isFileMessage?: boolean;
isFromVisitor?: boolean;
isLobbyMessage: boolean;
isModerated?: boolean;
message: IMessage;
onEditMessage?: () => void;
participantId: string;
}
const useStyles = makeStyles()(theme => {
return {
messageMenuButton: {
padding: '2px'
},
menuItem: {
padding: '8px 16px',
cursor: 'pointer',
color: 'white',
'&:hover': {
backgroundColor: theme.palette.action03
}
},
menuPanel: {
backgroundColor: theme.palette.chatInputBackground,
borderRadius: theme.shape.borderRadius,
boxShadow: theme.shadows[3],
overflow: 'hidden'
},
copiedMessage: {
position: 'fixed',
backgroundColor: theme.palette.chatInputBackground,
color: 'white',
padding: '4px 8px',
borderRadius: '4px',
fontSize: '0.75rem',
zIndex: 1000,
opacity: 0,
transition: 'opacity 0.3s ease-in-out',
pointerEvents: 'none'
},
showCopiedMessage: {
opacity: 1
}
};
});
const MessageMenu = ({ canEdit, message, isFromVisitor, isLobbyMessage, isModerated, enablePrivateChat, displayName, isFileMessage, onEditMessage }: IProps) => {
const dispatch = useDispatch();
const { classes, cx } = useStyles();
const { t } = useTranslation();
const [ isPopoverOpen, setIsPopoverOpen ] = useState(false);
const [ showCopiedMessage, setShowCopiedMessage ] = useState(false);
const [ popupPosition, setPopupPosition ] = useState({ top: 0,
left: 0 });
const buttonRef = useRef<HTMLDivElement>(null);
const isModerator = useSelector(isLocalParticipantModerator);
const messageModerationSupported = useSelector(
(state: IReduxState) => state['features/chat'].messageModerationSupported);
const participant = useSelector((state: IReduxState) => getParticipantById(state, message.participantId));
// Editing and deleting are applied by the server, so only offer them where the
// room says it handles them.
const canEditMessage = canEdit && messageModerationSupported;
// If no menu items will be shown, don't render the menu button.
if (!enablePrivateChat && isFileMessage && !canEditMessage) {
return null;
}
const handleMenuClick = useCallback(() => {
setIsPopoverOpen(true);
}, []);
const handleClose = useCallback(() => {
setIsPopoverOpen(false);
}, []);
const handlePrivateClick = useCallback(() => {
if (isLobbyMessage) {
dispatch(handleLobbyChatInitialized(message.participantId));
} else {
// For visitor messages, participant will be undefined but we can still open chat
// using the participantId which contains the visitor's original JID
if (isFromVisitor) {
// Handle visitor participant that doesn't exist in main participant list
const visitorParticipant = {
id: message.participantId,
name: displayName,
isVisitor: true
};
dispatch(openChat(visitorParticipant));
} else {
dispatch(openChat(participant));
}
}
handleClose();
}, [ dispatch, isLobbyMessage, participant, message.participantId, displayName ]);
const handleCopyClick = useCallback(() => {
copyText(message.message)
.then(success => {
if (success) {
if (buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect();
setPopupPosition({
top: rect.top - 30,
left: rect.left
});
}
setShowCopiedMessage(true);
setTimeout(() => {
setShowCopiedMessage(false);
}, 2000);
} else {
logger.error('Failed to copy text');
}
})
.catch((error: Error) => {
logger.error('Error copying text', error);
});
handleClose();
}, [ message.message ]);
const handleDeleteClick = useCallback(() => {
dispatch(sendMessageRetraction(message));
handleClose();
}, [ message, handleClose ]);
const handleModerateClick = useCallback(() => {
dispatch(sendMessageModeration(message));
handleClose();
}, [ dispatch, message, handleClose ]);
const handleEditClick = useCallback(() => {
onEditMessage?.();
handleClose();
}, [ onEditMessage, handleClose ]);
const popoverContent = (
<div className = { classes.menuPanel }>
{canEditMessage && (
<div
className = { classes.menuItem }
onClick = { handleEditClick }>
{t('Edit')}
</div>
)}
{enablePrivateChat && (
<div
className = { classes.menuItem }
onClick = { handlePrivateClick }>
{t('Private Message')}
</div>
)}
{!isFileMessage && (
<div
className = { classes.menuItem }
onClick = { handleCopyClick }>
{t('Copy')}
</div>
)}
{isModerator
&& !isModerated
&& messageModerationSupported
&& message.messageType !== MESSAGE_TYPE_LOCAL
&& (
<div
className = { classes.menuItem }
onClick = { handleModerateClick }>
{t('chat.delete')}
</div>
)}
{message.messageType === MESSAGE_TYPE_LOCAL
&& !message.isDeleted
&& !message.isModerated
&& messageModerationSupported
&& (
<div
className = { classes.menuItem }
onClick = { handleDeleteClick }>
{t('chat.deleteMessage')}
</div>
)}
</div>
);
return (
<div>
<div ref = { buttonRef }>
<Popover
content = { popoverContent }
onPopoverClose = { handleClose }
position = 'top'
trigger = 'click'
visible = { isPopoverOpen }>
<Button
accessibilityLabel = { t('toolbar.accessibilityLabel.moreOptions') }
className = { classes.messageMenuButton }
icon = { IconDotsHorizontal }
onClick = { handleMenuClick }
type = { BUTTON_TYPES.TERTIARY } />
</Popover>
</div>
{showCopiedMessage && ReactDOM.createPortal(
<div
className = { cx(classes.copiedMessage, { [classes.showCopiedMessage]: showCopiedMessage }) }
style = {{ top: `${popupPosition.top}px`,
left: `${popupPosition.left}px` }}>
{t('Message Copied')}
</div>,
document.body
)}
</div>
);
};
export default MessageMenu;

View File

@@ -0,0 +1,99 @@
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { connect } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { IconCloseLarge } from '../../../base/icons/svg';
import Button from '../../../base/ui/components/web/Button';
import { BUTTON_TYPES } from '../../../base/ui/constants.any';
import {
IProps,
_mapDispatchToProps,
_mapStateToProps
} from '../AbstractMessageRecipient';
const useStyles = makeStyles()(theme => {
return {
container: {
margin: '0 16px 8px',
padding: '6px',
paddingLeft: '16px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
backgroundColor: theme.palette.support05,
borderRadius: theme.shape.borderRadius,
...theme.typography.bodyShortRegular,
color: theme.palette.chatRecipientText
},
text: {
maxWidth: 'calc(100% - 30px)',
overflow: 'hidden',
whiteSpace: 'break-spaces',
wordBreak: 'break-all'
},
iconButton: {
padding: '2px',
'&:hover': {
backgroundColor: theme.palette.action03
}
}
};
});
const MessageRecipient = ({
_privateMessageRecipient,
_isLobbyChatActive,
_isVisitor,
_lobbyMessageRecipient,
_onRemovePrivateMessageRecipient,
_onHideLobbyChatRecipient,
_visible
}: IProps) => {
const { classes } = useStyles();
const { t } = useTranslation();
const _onKeyPress = useCallback((e: React.KeyboardEvent) => {
if (
(_onRemovePrivateMessageRecipient || _onHideLobbyChatRecipient)
&& (e.key === ' ' || e.key === 'Enter')
) {
e.preventDefault();
if (_isLobbyChatActive && _onHideLobbyChatRecipient) {
_onHideLobbyChatRecipient();
} else if (_onRemovePrivateMessageRecipient) {
_onRemovePrivateMessageRecipient();
}
}
}, [ _onRemovePrivateMessageRecipient, _onHideLobbyChatRecipient, _isLobbyChatActive ]);
if ((!_privateMessageRecipient && !_isLobbyChatActive) || !_visible) {
return null;
}
return (
<div
className = { classes.container }
id = 'chat-recipient'
role = 'alert'>
<span className = { classes.text }>
{ _isLobbyChatActive
? t('chat.lobbyChatMessageTo', { recipient: _lobbyMessageRecipient })
: t('chat.messageTo', { recipient: `${_privateMessageRecipient}${_isVisitor ? ` ${t('visitors.chatIndicator')}` : ''}` }) }
</span>
<Button
accessibilityLabel = { t('dialog.close') }
className = { classes.iconButton }
icon = { IconCloseLarge }
onClick = { _isLobbyChatActive
? _onHideLobbyChatRecipient : _onRemovePrivateMessageRecipient }
onKeyPress = { _onKeyPress }
type = { BUTTON_TYPES.TERTIARY } />
</div>
);
};
export default connect(_mapStateToProps, _mapDispatchToProps)(MessageRecipient);

View File

@@ -0,0 +1,89 @@
import React from 'react';
import { WithTranslation } from 'react-i18next';
import { makeStyles } from 'tss-react/mui';
import { translate } from '../../../base/i18n/functions';
import Icon from '../../../base/icons/components/Icon';
import { IconArrowDown } from '../../../base/icons/svg';
import BaseTheme from '../../../base/ui/components/BaseTheme.web';
export interface INewMessagesButtonProps extends WithTranslation {
/**
* Function to notify messageContainer when click on goToFirstUnreadMessage button.
*/
onGoToFirstUnreadMessage: () => void;
}
const useStyles = makeStyles()(theme => {
return {
container: {
position: 'absolute',
left: 'calc(50% - 72px)',
bottom: '15px'
},
newMessagesButton: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
height: '32px',
padding: '8px',
border: 'none',
borderRadius: theme.shape.borderRadius,
backgroundColor: theme.palette.action02,
boxShadow: '0px 3px 16px rgba(0, 0, 0, 0.6), 0px 0px 4px 1px rgba(0, 0, 0, 0.25)',
'&:hover': {
backgroundColor: theme.palette.action02Hover
},
'&:active': {
backgroundColor: theme.palette.action02Active
}
},
arrowDownIconContainer: {
height: '20px',
width: '20px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
},
textContainer: {
...theme.typography.bodyShortRegular,
color: theme.palette.text04,
paddingLeft: '8px'
}
};
});
/** NewMessagesButton.
*
* @param {Function} onGoToFirstUnreadMessage - Function for lifting up onClick event.
* @returns {JSX.Element}
*/
function NewMessagesButton({ onGoToFirstUnreadMessage, t }: INewMessagesButtonProps): JSX.Element {
const { classes: styles } = useStyles();
return (
<div
className = { styles.container }>
<button
aria-label = { t('chat.newMessages') }
className = { styles.newMessagesButton }
onClick = { onGoToFirstUnreadMessage }
type = 'button'>
<Icon
className = { styles.arrowDownIconContainer }
color = { BaseTheme.palette.icon04 }
size = { 14 }
src = { IconArrowDown } />
<div className = { styles.textContainer }> { t('chat.newMessages') }</div>
</button>
</div>
);
}
export default translate(NewMessagesButton);

View File

@@ -0,0 +1,74 @@
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { IReduxState } from '../../../app/types';
import { CHAT_ENABLED } from '../../../base/flags/constants';
import { getFeatureFlag } from '../../../base/flags/functions';
import { IconReply } from '../../../base/icons/svg';
import { getParticipantById } from '../../../base/participants/functions';
import Button from '../../../base/ui/components/web/Button';
import { BUTTON_TYPES } from '../../../base/ui/constants.any';
import { handleLobbyChatInitialized, openChat } from '../../actions.web';
export interface IProps {
/**
* True if the message is a lobby chat message.
*/
isLobbyMessage: boolean;
/**
* The ID of the participant that the message is to be sent.
*/
participantID: string;
/**
* Whether the button should be visible or not.
*/
visible?: boolean;
}
const useStyles = makeStyles()(theme => {
return {
replyButton: {
padding: '2px',
'&:hover': {
backgroundColor: theme.palette.action03
}
}
};
});
const PrivateMessageButton = ({ participantID, isLobbyMessage, visible }: IProps) => {
const { classes } = useStyles();
const dispatch = useDispatch();
const participant = useSelector((state: IReduxState) => getParticipantById(state, participantID));
const isVisible = useSelector((state: IReduxState) => getFeatureFlag(state, CHAT_ENABLED, true)) ?? visible;
const { t } = useTranslation();
const handleClick = useCallback(() => {
if (isLobbyMessage) {
dispatch(handleLobbyChatInitialized(participantID));
} else {
dispatch(openChat(participant));
}
}, []);
if (!isVisible) {
return null;
}
return (
<Button
accessibilityLabel = { t('toolbar.accessibilityLabel.privateMessage') }
className = { classes.replyButton }
icon = { IconReply }
onClick = { handleClick }
type = { BUTTON_TYPES.TERTIARY } />
);
};
export default PrivateMessageButton;

View File

@@ -0,0 +1,87 @@
import { Theme } from '@mui/material';
import React, { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { IconFaceSmile } from '../../../base/icons/svg';
import Popover from '../../../base/popover/components/Popover.web';
import Button from '../../../base/ui/components/web/Button';
import { BUTTON_TYPES } from '../../../base/ui/constants.any';
import { sendReaction } from '../../actions.any';
import EmojiSelector from './EmojiSelector';
interface IProps {
messageId: string;
receiverId: string;
}
const useStyles = makeStyles()((theme: Theme) => {
return {
reactButton: {
padding: '2px'
},
reactionPanelContainer: {
position: 'relative',
display: 'inline-block'
},
popoverContent: {
backgroundColor: theme.palette.background.paper,
borderRadius: theme.shape.borderRadius,
boxShadow: theme.shadows[3],
overflow: 'hidden'
}
};
});
const ReactButton = ({ messageId, receiverId }: IProps) => {
const { classes } = useStyles();
const dispatch = useDispatch();
const { t } = useTranslation();
const onSendReaction = useCallback(emoji => {
dispatch(sendReaction(emoji, messageId, receiverId));
}, [ dispatch, messageId, receiverId ]);
const [ isPopoverOpen, setIsPopoverOpen ] = useState(false);
const handleReactClick = useCallback(() => {
setIsPopoverOpen(true);
}, []);
const handleClose = useCallback(() => {
setIsPopoverOpen(false);
}, []);
const handleEmojiSelect = useCallback((emoji: string) => {
onSendReaction(emoji);
handleClose();
}, [ onSendReaction, handleClose ]);
const popoverContent = (
<div className = { classes.popoverContent }>
<EmojiSelector onSelect = { handleEmojiSelect } />
</div>
);
return (
<Popover
content = { popoverContent }
onPopoverClose = { handleClose }
position = 'top'
trigger = 'click'
visible = { isPopoverOpen }>
<div className = { classes.reactionPanelContainer }>
<Button
accessibilityLabel = { t('toolbar.accessibilityLabel.react') }
className = { classes.reactButton }
icon = { IconFaceSmile }
onClick = { handleReactClick }
type = { BUTTON_TYPES.TERTIARY } />
</div>
</Popover>
);
};
export default ReactButton;

View File

@@ -0,0 +1,120 @@
import React, { PureComponent } from 'react';
import Emoji from 'react-emoji-render';
import Tooltip from '../../../base/tooltip/components/Tooltip';
import { smileys } from '../../smileys';
/**
* The type of the React {@code Component} props of {@link SmileysPanel}.
*/
interface IProps {
/**
* Callback to invoke when a smiley is selected. The smiley will be passed
* back.
*/
onSmileySelect: Function;
}
/**
* Implements a React Component showing smileys that can be be shown in chat.
*
* @augments Component
*/
class SmileysPanel extends PureComponent<IProps> {
/**
* Initializes a new {@code SmileysPanel} instance.
*
* @param {*} props - The read-only properties with which the new instance
* is to be initialized.
*/
constructor(props: IProps) {
super(props);
// Bind event handler so it is only bound once for every instance.
this._onClick = this._onClick.bind(this);
this._onKeyPress = this._onKeyPress.bind(this);
this._onEscKey = this._onEscKey.bind(this);
}
/**
* KeyPress handler for accessibility.
*
* @param {Object} e - The key event to handle.
*
* @returns {void}
*/
_onEscKey(e: React.KeyboardEvent) {
// Escape handling does not work in onKeyPress
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
this.props.onSmileySelect();
}
}
/**
* KeyPress handler for accessibility.
*
* @param {Object} e - The key event to handle.
*
* @returns {void}
*/
_onKeyPress(e: React.KeyboardEvent<HTMLDivElement>) {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault(); // @ts-ignore
this.props.onSmileySelect(e.target.id && smileys[e.target.id]);
}
}
/**
* Click handler for to select emoji.
*
* @param {Object} e - The key event to handle.
*
* @returns {void}
*/
_onClick(e: React.MouseEvent) {
e.preventDefault();
this.props.onSmileySelect(e.currentTarget.id && smileys[e.currentTarget.id as keyof typeof smileys]);
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
const smileyItems = Object.keys(smileys).map(smileyKey => (
<div
className = 'smileyContainer'
id = { smileyKey }
key = { smileyKey }
onClick = { this._onClick }
onKeyDown = { this._onEscKey }
onKeyPress = { this._onKeyPress }
role = 'option'
tabIndex = { 0 }>
<Tooltip content = { smileys[smileyKey as keyof typeof smileys] }>
<Emoji
onlyEmojiClassName = 'smiley'
text = { smileys[smileyKey as keyof typeof smileys] } />
</Tooltip>
</div>
));
return (
<div
aria-orientation = 'horizontal'
id = 'smileysContainer'
onKeyDown = { this._onEscKey }
role = 'listbox'
tabIndex = { -1 }>
{ smileyItems }
</div>
);
}
}
export default SmileysPanel;

View File

@@ -0,0 +1,96 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { getParticipantDisplayName } from '../../../base/participants/functions';
import { ISubtitle } from '../../../subtitles/types';
/**
* Props for the SubtitleMessage component.
*/
interface IProps extends ISubtitle {
/**
* Whether to show the display name of the participant.
*/
showDisplayName: boolean;
}
/**
* The styles for the SubtitleMessage component.
*/
const useStyles = makeStyles()(theme => {
return {
messageContainer: {
backgroundColor: theme.palette.subtitleMessageBackground,
borderRadius: '4px 12px 12px 12px',
padding: '12px',
maxWidth: '100%',
marginTop: '4px',
boxSizing: 'border-box',
display: 'inline-flex'
},
messageContent: {
maxWidth: '100%',
overflow: 'hidden',
flex: 1
},
messageHeader: {
...theme.typography.labelBold,
color: theme.palette.subtitleMessageSender,
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
overflow: 'hidden',
marginBottom: theme.spacing(1),
maxWidth: '130px'
},
messageText: {
...theme.typography.bodyShortRegular,
color: theme.palette.subtitleMessageText,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word'
},
timestamp: {
...theme.typography.labelRegular,
color: theme.palette.subtitleMessageTime,
marginTop: theme.spacing(1)
},
interim: {
opacity: 0.7
}
};
});
/**
* Component that renders a single subtitle message with the participant's name,
* message content, and timestamp.
*
* @param {IProps} props - The component props.
* @returns {JSX.Element} - The rendered subtitle message.
*/
export default function SubtitleMessage({ participantId, text, timestamp, interim, showDisplayName }: IProps) {
const { classes } = useStyles();
const participantName = useSelector((state: any) =>
getParticipantDisplayName(state, participantId));
return (
<div className = { `${classes.messageContainer} ${interim ? classes.interim : ''}` }>
<div className = { classes.messageContent }>
{showDisplayName && (
<div className = { classes.messageHeader }>
{participantName}
</div>
)}
<div className = { classes.messageText }>{text}</div>
<div className = { classes.timestamp }>
{new Date(timestamp).toLocaleTimeString()}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,76 @@
import React from 'react';
import { makeStyles } from 'tss-react/mui';
import Avatar from '../../../base/avatar/components/Avatar';
import { ISubtitle } from '../../../subtitles/types';
import SubtitleMessage from './SubtitleMessage';
/**
* Props for the SubtitlesGroup component.
*/
interface IProps {
/**
* Array of subtitle messages to be displayed in this group.
*/
messages: ISubtitle[];
/**
* The ID of the participant who sent these subtitles.
*/
senderId: string;
}
const useStyles = makeStyles()(theme => {
return {
groupContainer: {
display: 'flex',
marginBottom: theme.spacing(3)
},
avatar: {
marginRight: theme.spacing(2),
alignSelf: 'flex-start'
},
messagesContainer: {
display: 'flex',
flexDirection: 'column',
flex: 1,
maxWidth: 'calc(100% - 56px)', // 40px avatar + 16px margin
gap: theme.spacing(1)
}
};
});
/**
* Component that renders a group of subtitle messages from the same sender.
*
* @param {IProps} props - The props for the component.
* @returns {JSX.Element} - A React component rendering a group of subtitles.
*/
export function SubtitlesGroup({ messages, senderId }: IProps) {
const { classes } = useStyles();
if (!messages.length) {
return null;
}
return (
<div className = { classes.groupContainer }>
<Avatar
className = { classes.avatar }
participantId = { senderId }
size = { 32 } />
<div className = { classes.messagesContainer }>
{messages.map((message, index) => (
<SubtitleMessage
key = { `${message.timestamp}-${message.id}` }
showDisplayName = { index === 0 }
{ ...message } />
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,178 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { scrollIntoView } from 'seamless-scroll-polyfill';
import { makeStyles } from 'tss-react/mui';
import { ISubtitle } from '../../../subtitles/types';
import NewMessagesButton from './NewMessagesButton';
import { SubtitlesGroup } from './SubtitlesGroup';
interface IProps {
groups: Array<{
messages: ISubtitle[];
senderId: string;
}>;
/**
* Whether the subtitles container is currently visible to the user.
*/
isVisible: boolean;
messages: ISubtitle[];
}
/**
* The padding value used for the message list.
*
* @constant {string}
*/
const MESSAGE_LIST_PADDING = '16px';
const useStyles = makeStyles()(() => {
return {
container: {
flex: 1,
overflow: 'hidden',
position: 'relative',
height: '100%'
},
messagesList: {
height: '100%',
overflowY: 'auto',
padding: MESSAGE_LIST_PADDING,
boxSizing: 'border-box'
}
};
});
/**
* Component that handles the display and scrolling behavior of subtitles messages.
* It provides auto-scrolling for new messages and a button to jump to new messages
* when the user has scrolled up.
*
* @returns {JSX.Element} - A React component displaying subtitles messages with scroll functionality.
*/
export function SubtitlesMessagesContainer({ messages, groups, isVisible }: IProps) {
const { classes } = useStyles();
const [ hasNewMessages, setHasNewMessages ] = useState(false);
const [ isScrolledToBottom, setIsScrolledToBottom ] = useState(true);
const [ observer, setObserver ] = useState<IntersectionObserver | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const hasInitiallyScrolled = useRef(false);
const scrollToElement = useCallback((withAnimation: boolean, element: Element | null) => {
const scrollTo = element ? element : messagesEndRef.current;
const block = element ? 'end' : 'nearest';
scrollIntoView(scrollTo as Element, {
behavior: withAnimation ? 'smooth' : 'auto',
block
});
}, [ messagesEndRef.current ]);
const handleNewMessagesClick = useCallback(() => {
scrollToElement(true, null);
}, [ scrollToElement ]);
const handleIntersectBottomList = (entries: IntersectionObserverEntry[]) => {
entries.forEach((entry: IntersectionObserverEntry) => {
if (entry.isIntersecting) {
setIsScrolledToBottom(true);
setHasNewMessages(false);
}
if (!entry.isIntersecting) {
setIsScrolledToBottom(false);
}
});
};
const createBottomListObserver = () => {
const target = document.querySelector('#subtitles-messages-end');
if (target) {
const newObserver = new IntersectionObserver(
handleIntersectBottomList, {
root: document.querySelector('#subtitles-messages-list'),
rootMargin: MESSAGE_LIST_PADDING,
threshold: 1
});
setObserver(newObserver);
newObserver.observe(target);
}
};
useEffect(() => {
// Only scroll on mount if the component is visible, since scrollIntoView
// silently does nothing on hidden (display: none) elements. If hidden,
// the isVisible useEffect below will handle scrolling when it first becomes visible.
if (isVisible) {
hasInitiallyScrolled.current = true;
requestAnimationFrame(() => {
scrollToElement(false, null);
});
}
createBottomListObserver();
return () => {
if (observer) {
observer.disconnect();
setObserver(null);
}
};
}, []);
useEffect(() => {
if (isVisible && !hasInitiallyScrolled.current) {
hasInitiallyScrolled.current = true;
requestAnimationFrame(() => {
scrollToElement(false, null);
});
}
}, [ isVisible, scrollToElement ]);
const previousMessages = useRef(messages);
useEffect(() => {
const newMessages = messages.filter(message => !previousMessages.current.includes(message));
if (newMessages.length > 0) {
if (isScrolledToBottom) {
scrollToElement(false, null);
} else {
setHasNewMessages(true);
}
}
previousMessages.current = messages;
},
// isScrolledToBottom is not a dependency because we neither need to show the new messages button neither scroll to the
// bottom when the user has scrolled up.
[ messages, scrollToElement ]);
return (
<div
className = { classes.container }
id = 'subtitles-messages-container'>
<div
className = { classes.messagesList }
id = 'subtitles-messages-list'>
{groups.map(group => (
<SubtitlesGroup
key = { `${group.senderId}-${group.messages[0].timestamp}` }
messages = { group.messages }
senderId = { group.senderId } />
))}
{ !isScrolledToBottom && hasNewMessages && (
<NewMessagesButton
onGoToFirstUnreadMessage = { handleNewMessagesClick } />
)}
<div
id = 'subtitles-messages-end'
ref = { messagesEndRef } />
</div>
</div>
);
}

View File

@@ -0,0 +1,84 @@
/**
* Maximum number of characters allowed.
*/
export const CHAR_LIMIT = 500;
/**
* The initial size of the chat.
*/
export const CHAT_SIZE = 315;
/**
* The audio ID of the audio element for which the {@link playAudio} action is
* triggered when new chat message is received.
*
* @type {string}
*/
export const INCOMING_MSG_SOUND_ID = 'INCOMING_MSG_SOUND';
/**
* The {@code messageType} of error (system) messages.
*/
export const MESSAGE_TYPE_ERROR = 'error' as const;
/**
* The {@code messageType} of local messages.
*/
export const MESSAGE_TYPE_LOCAL = 'local' as const;
/**
* The {@code messageType} of remote messages.
*/
export const MESSAGE_TYPE_REMOTE = 'remote' as const;
export const SMALL_WIDTH_THRESHOLD = 580;
/**
* Drag handle dimensions for resizable chat.
*/
export const CHAT_DRAG_HANDLE_WIDTH = 9;
export const CHAT_DRAG_HANDLE_HEIGHT = 100;
/**
* Touch target size for chat drag handle on touch devices.
* Provides adequate hit area (44px) for comfortable tapping.
*/
export const CHAT_TOUCH_HANDLE_SIZE = 44;
/**
* Offset from edge for positioning the chat drag handle.
*/
export const CHAT_DRAG_HANDLE_OFFSET = 4;
/**
* Lobby message type.
*/
export const LOBBY_CHAT_MESSAGE = 'LOBBY_CHAT_MESSAGE';
export enum ChatTabs {
CHAT = 'chat-tab',
CLOSED_CAPTIONS = 'cc-tab',
FILE_SHARING = 'file_sharing-tab',
POLLS = 'polls-tab'
}
/**
* 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';
export const OPTION_GROUPCHAT = 'groupchat';
export const MODERATE_CHAT_MESSAGE = 'MODERATE_CHAT_MESSAGE';
/**
* Maximum number of pending edits are allowed.
*/
export const MAX_PENDING_EDITS = 200;

View File

@@ -0,0 +1,484 @@
// @ts-expect-error
import aliases from 'react-emoji-render/data/aliases';
// eslint-disable-next-line lines-around-comment
// @ts-expect-error
import emojiAsciiAliases from 'react-emoji-render/data/asciiAliases';
import { createSelector } from 'reselect';
import { IReduxState } from '../app/types';
import { getLocalizedDateFormatter } from '../base/i18n/dateUtil';
import i18next from '../base/i18n/i18next';
import { MEET_FEATURES } from '../base/jwt/constants';
import { isJwtFeatureEnabled } from '../base/jwt/functions';
import { getParticipantById, isPrivateChatEnabled } from '../base/participants/functions';
import { IParticipant } from '../base/participants/types';
import { escapeRegexp } from '../base/util/helpers';
import { arePollsDisabled } from '../conference/functions.any';
import { getCustomPanelWidth } from '../custom-panel/functions';
import { isFileSharingEnabled } from '../file-sharing/functions.any';
import { getParticipantsPaneWidth } from '../participants-pane/functions';
import { isCCTabEnabled } from '../subtitles/functions.any';
import { VIDEO_SPACE_MIN_SIZE } from '../video-layout/constants';
import { IVisitorChatParticipant } from '../visitors/types';
import { ChatTabs, MAX_PENDING_EDITS, MESSAGE_TYPE_ERROR, MESSAGE_TYPE_LOCAL, TIMESTAMP_FORMAT } from './constants';
import { IMessage, IPendingEdit, IPendingEditsMap } from './types';
/**
* An ASCII emoticon regexp array to find and replace old-style ASCII
* emoticons (such as :O) with the new Unicode representation, so that
* devices and browsers that support them can render these natively
* without a 3rd party component.
*
* NOTE: this is currently only used on mobile, but it can be used
* on web too once we drop support for browsers that don't support
* unicode emoji rendering.
*/
const ASCII_EMOTICON_REGEXP_ARRAY: Array<[RegExp, string]> = [];
/**
* An emoji regexp array to find and replace alias emoticons
* (such as :smiley:) with the new Unicode representation, so that
* devices and browsers that support them can render these natively
* without a 3rd party component.
*
* NOTE: this is currently only used on mobile, but it can be used
* on web too once we drop support for browsers that don't support
* unicode emoji rendering.
*/
const SLACK_EMOJI_REGEXP_ARRAY: Array<[RegExp, string]> = [];
(function() {
for (const [ key, value ] of Object.entries(aliases)) {
// Add ASCII emoticons
const asciiEmoticons = emojiAsciiAliases[key];
if (asciiEmoticons) {
const asciiEscapedValues = asciiEmoticons.map((v: string) => escapeRegexp(v));
const asciiRegexp = `(${asciiEscapedValues.join('|')})`;
// Escape urls
const formattedAsciiRegexp = key === 'confused'
? `(?=(${asciiRegexp}))(:(?!//).)`
: asciiRegexp;
ASCII_EMOTICON_REGEXP_ARRAY.push([ new RegExp(formattedAsciiRegexp, 'g'), value as string ]);
}
// Add slack-type emojis
const emojiRegexp = `\\B(${escapeRegexp(`:${key}:`)})\\B`;
SLACK_EMOJI_REGEXP_ARRAY.push([ new RegExp(emojiRegexp, 'g'), value as string ]);
}
})();
/**
* Replaces ASCII and other non-unicode emoticons with unicode emojis to let the emojis be rendered
* by the platform native renderer.
*
* @param {string} message - The message to parse and replace.
* @returns {string}
*/
export function replaceNonUnicodeEmojis(message: string): string {
let replacedMessage = message;
for (const [ regexp, replaceValue ] of SLACK_EMOJI_REGEXP_ARRAY) {
replacedMessage = replacedMessage.replace(regexp, replaceValue);
}
for (const [ regexp, replaceValue ] of ASCII_EMOTICON_REGEXP_ARRAY) {
replacedMessage = replacedMessage.replace(regexp, replaceValue);
}
return replacedMessage;
}
/**
* Selector for calculating the number of unread chat messages.
*
* @param {IReduxState} state - The redux state.
* @returns {number} The number of unread messages.
*/
export function getUnreadCount(state: IReduxState): number {
const { lastReadMessage, messages } = state['features/chat'];
const messagesCount = messages.length;
if (!messagesCount) {
return 0;
}
let reactionMessages = 0;
let lastReadIndex: number;
if (navigator.product === 'ReactNative') {
// React native stores the messages in a reversed order.
lastReadIndex = messages.indexOf(<IMessage>lastReadMessage);
for (let i = 0; i < lastReadIndex; i++) {
if (messages[i].isReaction) {
reactionMessages++;
}
}
return lastReadIndex - reactionMessages;
}
lastReadIndex = messages.lastIndexOf(<IMessage>lastReadMessage);
for (let i = lastReadIndex + 1; i < messagesCount; i++) {
if (messages[i].isReaction) {
reactionMessages++;
}
}
return messagesCount - (lastReadIndex + 1) - reactionMessages;
}
/**
* Gets the unread files count.
*
* @param {IReduxState} state - The redux state.
* @returns {number} The number of unread files.
*/
export function getUnreadFilesCount(state: IReduxState): number {
return state['features/chat']?.unreadFilesCount || 0;
}
/**
* Get whether the chat smileys are disabled or not.
*
* @param {IReduxState} state - The redux state.
* @returns {boolean} The disabled flag.
*/
export function areSmileysDisabled(state: IReduxState): boolean {
const disableChatSmileys = state['features/base/config']?.disableChatSmileys === true;
return disableChatSmileys;
}
/**
* Returns whether the chat feature is disabled.
*
* @param {IReduxState} state - The redux state.
* @returns {boolean} True if chat is disabled, false otherwise.
*/
export function isChatDisabled(state: IReduxState): boolean {
return Boolean(state['features/base/config']?.disableChat);
}
/**
* Gets the default focused tab based on what features are enabled.
* Returns the first available tab in priority order: CHAT -> POLLS -> FILE_SHARING -> CLOSED_CAPTIONS.
*
* @param {IReduxState} state - The redux state.
* @returns {ChatTabs | undefined} The default focused tab.
*/
export function getDefaultFocusedTab(state: IReduxState): ChatTabs | undefined {
if (!isChatDisabled(state)) {
return ChatTabs.CHAT;
}
if (!arePollsDisabled(state)) {
return ChatTabs.POLLS;
}
if (isFileSharingEnabled(state)) {
return ChatTabs.FILE_SHARING;
}
if (isCCTabEnabled(state)) {
return ChatTabs.CLOSED_CAPTIONS;
}
return undefined;
}
/**
* Returns the currently focused tab or the default focused tab if none is set.
*
* @param {IReduxState} state - The redux state.
* @returns {ChatTabs | undefined} The focused tab or undefined if no tabs are available.
*/
export function getFocusedTab(state: IReduxState): ChatTabs | undefined {
return state['features/chat'].focusedTab || getDefaultFocusedTab(state);
}
/**
* Returns the timestamp to display for the message.
*
* @param {IMessage} message - The message from which to get the timestamp.
* @returns {string}
*/
export function getFormattedTimestamp(message: IMessage): string {
return getLocalizedDateFormatter(new Date(message.timestamp))
.format(TIMESTAMP_FORMAT);
}
/**
* Generates the message text to be rendered in the component.
*
* @param {IMessage} message - The message from which to get the text.
* @returns {string}
*/
export function getMessageText(message: IMessage): string {
return message.messageType === MESSAGE_TYPE_ERROR
? i18next.t('chat.error', {
error: message.message
})
: message.message;
}
/**
* Escapes regexp special characters in a string.
*
* @param {string} value - The string to escape.
* @returns {string}
*/
export function escapeSearchRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Splits message text into segments, flagging which ones match the search
* query (case-insensitive), so callers can render matches with a highlight
* style. Shared between web and native so both wrap matches identically.
*
* @param {string} text - The message text.
* @param {string} query - The search query.
* @returns {Array<Object>}
*/
export function getHighlightSegments(text: string, query: string): Array<{ match: boolean; text: string; }> {
if (!query) {
return [ { match: false,
text } ];
}
const regexp = new RegExp(`(${escapeSearchRegExp(query)})`, 'gi');
return text
.split(regexp)
.filter(part => part.length > 0)
.map(part => ({
match: part.toLowerCase() === query.toLowerCase(),
text: part
}));
}
/**
* 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): boolean {
const { knocking } = state['features/lobby'];
const participant = getParticipantById(state, message.participantId);
// Check if basic reply conditions are met
const basicCanReply = (Boolean(participant) || message.isFromVisitor)
&& (message.privateMessage || (message.lobbyChat && !knocking))
&& message.messageType !== MESSAGE_TYPE_LOCAL;
if (!basicCanReply) {
return false;
}
// Check private chat configuration for visitor messages
if (message.isFromVisitor) {
const visitorParticipant = { id: message.participantId, name: message.displayName, isVisitor: true as const };
return isPrivateChatEnabled(visitorParticipant, state);
}
// For non-visitor messages, use the regular participant
return isPrivateChatEnabled(participant, state);
}
/**
* Returns the message that is displayed as a notice for private messages.
*
* @param {IMessage} message - The message to be checked.
* @returns {string}
*/
export function getPrivateNoticeMessage(message: IMessage): string {
let recipient;
if (message.messageType === MESSAGE_TYPE_LOCAL) {
// For messages sent by local user, show the recipient name
// For visitor messages, use the visitor's display name with indicator
recipient = message.sentToVisitor ? `${message.recipient} ${i18next.t('visitors.chatIndicator')}` : message.recipient;
} else {
// For messages received from others, show "you"
recipient = i18next.t('chat.you');
}
return i18next.t('chat.privateNotice', {
recipient
});
}
/**
* Check if participant is not allowed to send group messages.
*
* @param {IReduxState} state - The redux state.
* @returns {boolean} - Returns true if the participant is not allowed to send group messages.
*/
export function isSendGroupChatDisabled(state: IReduxState): boolean {
const { groupChatRequiresPermission } = state['features/dynamic-branding'];
if (!groupChatRequiresPermission) {
return false;
}
return !isJwtFeatureEnabled(state, MEET_FEATURES.SEND_GROUPCHAT, false);
}
/**
* Calculates the maximum width available for the chat panel based on the current window size
* and other UI elements.
*
* @param {IReduxState} state - The Redux state containing the application's current state.
* @returns {number} The maximum width in pixels available for the chat panel. Returns 0 if there
* is no space available.
*/
export function getChatMaxSize(state: IReduxState): number {
const { clientWidth } = state['features/base/responsive-ui'];
return Math.max(
clientWidth - getParticipantsPaneWidth(state) - getCustomPanelWidth(state) - VIDEO_SPACE_MIN_SIZE,
0
);
}
/**
* Type guard to check if a participant is a visitor chat participant.
*
* @param {IParticipant | IVisitorChatParticipant | undefined} participant - The participant to check.
* @returns {boolean} - True if the participant is a visitor chat participant.
*/
export function isVisitorChatParticipant(
participant?: IParticipant | IVisitorChatParticipant
): participant is IVisitorChatParticipant {
return Boolean(participant && 'isVisitor' in participant && participant.isVisitor === true);
}
/**
* Returns a suffix to be appended to the display name based on the message origin.
*
* @param {IMessage} message - The message.
* @returns {string} The suffix, if any.
*/
export function getDisplayNameSuffix(message: IMessage): string {
let suffix = '';
if (message.isFromVisitor) {
suffix = ` ${i18next.t('visitors.chatIndicator')}`;
} else if (message.isFromGuest) {
suffix = ` ${i18next.t('chat.guestsChatIndicator')}`;
}
return suffix;
}
/**
* Checks if a message is a file message by verifying the presence of file metadata.
*
* @param {IMessage} message - The message to check.
* @returns {boolean} True if the message contains file metadata, false otherwise.
*/
export function isFileMessage(message: IMessage): boolean {
return Boolean(message?.fileMetadata);
}
/**
* Inserts a pending edit into the map, evicting the oldest entry if the cap is exceeded.
*
* @param {IPendingEditsMap} pendingEdits - The current pending edits map.
* @param {string} messageId - The id of the message the edit targets.
* @param {IPendingEdit} edit - The pending edit to insert.
* @returns {IPendingEditsMap} The updated pending edits map.
*/
export function addPendingEdit(
pendingEdits: IPendingEditsMap,
messageId: string,
edit: IPendingEdit
): IPendingEditsMap {
const next = { ...pendingEdits, [messageId]: edit };
const keys = Object.keys(next);
if (keys.length > MAX_PENDING_EDITS) {
// Object key order is insertion order for string keys in JS, so the first key is the oldest.
delete next[keys[0]];
}
return next;
}
/*
* Returns the current chat search query.
*
* @param {IReduxState} state - The redux state.
* @returns {string}
*/
export function getChatSearchQuery(state: IReduxState): string {
return state['features/chat'].searchQuery || '';
}
/**
* Stable empty-array reference returned when there is no active search query, so repeated
* "no query" calls return the same reference instead of a new empty array each time.
*/
const NO_SEARCH_MATCHES: IMessage[] = [];
const _getMessages = (state: IReduxState) => state['features/chat'].messages;
/**
* Returns all messages matching the current search query, in display order.
* Reaction and file messages are excluded since they have no searchable text.
* Memoized: only recomputes when messages or the search query change.
*
* @returns {IMessage[]}
*/
export const getChatSearchMatches = createSelector(
_getMessages,
getChatSearchQuery,
(messages, searchQuery): IMessage[] => {
const query = searchQuery?.trim().toLowerCase();
if (!query) {
return NO_SEARCH_MATCHES;
}
return messages.filter(message =>
!message.isReaction
&& !isFileMessage(message)
&& getMessageText(message).toLowerCase().includes(query));
}
);
/**
* Returns the index of the currently focused search match.
*
* @param {IReduxState} state - The redux state.
* @returns {number}
*/
export function getChatSearchMatchIndex(state: IReduxState): number {
return state['features/chat'].searchMatchIndex || 0;
}
/**
* Returns the message that is the currently focused search match, if any.
* Memoized via getChatSearchMatches — this is called from every ChatMessage's
* mapStateToProps, so keeping it cheap matters.
*
* @returns {IMessage | undefined}
*/
export const getActiveChatSearchMatch = createSelector(
getChatSearchMatches,
getChatSearchMatchIndex,
(matches, index): IMessage | undefined => matches[index]
);

View File

@@ -0,0 +1,31 @@
import { useSelector } from 'react-redux';
import ChatButton from './components/web/ChatButton';
import { isChatDisabled } from './functions';
interface IChatButtonEntry {
Content: typeof ChatButton;
group: number;
key: string;
}
const chat: IChatButtonEntry = {
key: 'chat',
Content: ChatButton,
group: 2
};
/**
* A hook that returns the chat button if chat is not disabled.
*
* @returns {Object | undefined} - The chat button object or undefined.
*/
export function useChatButton(): IChatButtonEntry | undefined {
const _isChatDisabled = useSelector(isChatDisabled);
if (_isChatDisabled) {
return;
}
return chat;
}

View File

@@ -0,0 +1,3 @@
import { getLogger } from '../base/logging/functions';
export default getLogger('app:chat');

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,434 @@
import { UPDATE_CONFERENCE_METADATA } from '../base/conference/actionTypes';
import { ILocalParticipant, IParticipant } from '../base/participants/types';
import ReducerRegistry from '../base/redux/ReducerRegistry';
import { ADD_FILE, _FILE_LIST_RECEIVED } from '../file-sharing/actionTypes';
import { IVisitorChatParticipant } from '../visitors/types';
import {
ADD_MESSAGE,
ADD_MESSAGE_REACTION,
CLEAR_CHAT_SEARCH,
CLEAR_CHAT_STATE,
CLOSE_CHAT,
EDIT_MESSAGE,
MODERATE_MESSAGE,
NOTIFY_PRIVATE_RECIPIENTS_CHANGED,
OPEN_CHAT,
REMOVE_LOBBY_CHAT_PARTICIPANT,
RETRACT_MESSAGE,
SET_CHAT_IS_RESIZING,
SET_CHAT_SEARCH_MATCH_INDEX,
SET_CHAT_SEARCH_QUERY,
SET_CHAT_WIDTH,
SET_FOCUSED_TAB,
SET_LOBBY_CHAT_ACTIVE_STATE,
SET_LOBBY_CHAT_RECIPIENT,
SET_MESSAGE_MODERATION_SUPPORTED,
SET_PRIVATE_MESSAGE_RECIPIENT,
SET_USER_CHAT_WIDTH
} from './actionTypes';
import { CHAT_SIZE, ChatTabs } from './constants';
import { addPendingEdit } from './functions';
import { IMessage, IPendingEditsMap } from './types';
const DEFAULT_STATE = {
groupChatWithPermissions: false,
isOpen: false,
messageModerationSupported: false,
messages: [],
notifyPrivateRecipientsChangedTimestamp: undefined,
pendingEdits: {},
reactions: {},
unreadMessagesCount: 0,
unreadFilesCount: 0,
privateMessageRecipient: undefined,
searchQuery: '',
searchMatchIndex: 0,
lobbyMessageRecipient: undefined,
isLobbyChatActive: false,
focusedTab: undefined,
isResizing: false,
width: {
current: CHAT_SIZE,
userSet: null
}
};
export interface IChatState {
focusedTab?: ChatTabs;
groupChatWithPermissions: boolean;
isLobbyChatActive: boolean;
isOpen: boolean;
isResizing: boolean;
lastReadMessage?: IMessage;
lobbyMessageRecipient?: {
id: string;
name: string;
} | ILocalParticipant;
messageModerationSupported: boolean;
messages: IMessage[];
notifyPrivateRecipientsChangedTimestamp?: number;
pendingEdits: IPendingEditsMap;
privateMessageRecipient?: IParticipant | IVisitorChatParticipant;
searchMatchIndex: number;
searchQuery: string;
unreadFilesCount: number;
unreadMessagesCount: number;
width: {
current: number;
userSet: number | null;
};
}
ReducerRegistry.register<IChatState>('features/chat', (state = DEFAULT_STATE, action): IChatState => {
switch (action.type) {
case ADD_MESSAGE: {
const newMessage: IMessage = {
displayName: action.displayName,
editedAt: action.editedAt,
error: action.error,
fileMetadata: action.fileMetadata,
isFromGuest: Boolean(action.isFromGuest),
isFromVisitor: Boolean(action.isFromVisitor),
participantId: action.participantId,
isEdited: Boolean(action.isEdited),
isReaction: action.isReaction,
messageId: action.messageId,
messageType: action.messageType,
message: action.message,
reactions: action.reactions,
privateMessage: action.privateMessage,
lobbyChat: action.lobbyChat,
recipient: action.recipient,
recipientId: action.recipientId,
replyToMessageId: action.replyToMessageId,
sentToVisitor: Boolean(action.sentToVisitor),
timestamp: action.timestamp
};
const pendingEdit = action.messageId ? state.pendingEdits[action.messageId] : undefined;
// only apply a cached pending edit if its claimed author matches
// the author of the message that actually arrived. Prevents a forged/mismatched
// edit that was queued for a not-yet-seen messageId from silently overwriting
// someone else's message once it shows up.
const pendingEditValid = pendingEdit?.participantId === action.participantId;
const finalMessage = pendingEdit && pendingEditValid
? {
...newMessage,
message: pendingEdit.message,
isEdited: true,
editedAt: pendingEdit.editedAt
}
: newMessage;
// React native, unlike web, needs a reverse sorted message list.
const messages = navigator.product === 'ReactNative'
? [
finalMessage,
...state.messages
]
: [
...state.messages,
finalMessage
];
const pendingEdits = { ...state.pendingEdits };
if (pendingEdit && pendingEditValid && action.messageId) {
delete pendingEdits[action.messageId];
}
return {
...state,
lastReadMessage:
action.hasRead ? finalMessage : state.lastReadMessage,
unreadMessagesCount: state.focusedTab !== ChatTabs.CHAT ? state.unreadMessagesCount + 1 : state.unreadMessagesCount,
messages,
pendingEdits
};
}
case ADD_MESSAGE_REACTION: {
const { participantId, reactionList, messageId } = action;
const messages = state.messages.map(message => {
if (messageId === message.messageId) {
const newReactions = new Map(message.reactions);
reactionList.forEach((reaction: string) => {
let participants = newReactions.get(reaction);
if (!participants) {
participants = new Set();
newReactions.set(reaction, participants);
}
participants.add(participantId);
});
return {
...message,
reactions: newReactions
};
}
return message;
});
return {
...state,
messages
};
}
case CLEAR_CHAT_SEARCH:
return {
...state,
searchQuery: '',
searchMatchIndex: 0
};
case CLEAR_CHAT_STATE:
return {
...DEFAULT_STATE,
width: state.width
};
case EDIT_MESSAGE: {
const newMessage = action.message;
let messageExists = false;
let applied = false;
const messages = state.messages.map(m => {
if (m.messageId === newMessage.messageId) {
messageExists = true;
if (!newMessage.participantId || newMessage.participantId !== m.participantId) {
return m;
}
applied = true;
return {
...m,
message: newMessage.message,
isEdited: true,
editedAt: newMessage.editedAt
};
}
return m;
});
// Message hasn't arrived yet — cache it as a pending edit so it can be
// applied later, once ADD_MESSAGE validates authorship.
if (!messageExists) {
return {
...state,
pendingEdits: addPendingEdit(state.pendingEdits, newMessage.messageId, {
message: newMessage.message,
editedAt: newMessage.editedAt,
participantId: newMessage.participantId
})
};
}
if (!applied) {
return state;
}
return {
...state,
messages
};
}
case MODERATE_MESSAGE: {
const messages = state.messages.map(message => {
if (message.messageId === action.messageId) {
return {
...message,
isModerated: true,
moderationReason: action.reason
};
}
return message;
});
return {
...state,
messages
};
}
case RETRACT_MESSAGE: {
const messages = state.messages.map(message => {
if (message.messageId === action.messageId && message.participantId === action.retractedBy) {
return {
...message,
message: '',
isDeleted: true,
retractedBy: action.retractedBy
};
}
return message;
});
return {
...state,
messages
};
}
case SET_MESSAGE_MODERATION_SUPPORTED:
return {
...state,
messageModerationSupported: action.supported
};
case SET_CHAT_SEARCH_MATCH_INDEX:
return {
...state,
searchMatchIndex: action.index
};
case SET_CHAT_SEARCH_QUERY:
return {
...state,
searchQuery: action.query,
searchMatchIndex: 0
};
case SET_PRIVATE_MESSAGE_RECIPIENT:
return {
...state,
privateMessageRecipient: action.participant
};
case OPEN_CHAT:
return {
...state,
isOpen: true,
privateMessageRecipient: action.participant
};
case CLOSE_CHAT:
return {
...state,
isOpen: false,
lastReadMessage: state.messages[
navigator.product === 'ReactNative' ? 0 : state.messages.length - 1],
privateMessageRecipient: action.participant,
isLobbyChatActive: false,
searchQuery: '',
searchMatchIndex: 0
};
case SET_LOBBY_CHAT_RECIPIENT:
return {
...state,
isLobbyChatActive: true,
lobbyMessageRecipient: action.participant,
privateMessageRecipient: undefined,
isOpen: action.open
};
case SET_LOBBY_CHAT_ACTIVE_STATE:
return {
...state,
isLobbyChatActive: action.payload,
isOpen: action.payload || state.isOpen,
privateMessageRecipient: undefined
};
case REMOVE_LOBBY_CHAT_PARTICIPANT:
return {
...state,
messages: state.messages.filter(m => {
if (action.removeLobbyChatMessages) {
return !m.lobbyChat;
}
return true;
}),
isOpen: state.isOpen && state.isLobbyChatActive ? false : state.isOpen,
isLobbyChatActive: false,
lobbyMessageRecipient: undefined
};
case UPDATE_CONFERENCE_METADATA: {
const { metadata } = action;
if (metadata?.permissions) {
return {
...state,
groupChatWithPermissions: Boolean(metadata.permissions.groupChatRestricted)
};
}
break;
}
case SET_FOCUSED_TAB:
return {
...state,
focusedTab: action.tabId,
unreadMessagesCount: action.tabId === ChatTabs.CHAT ? 0 : state.unreadMessagesCount,
unreadFilesCount: action.tabId === ChatTabs.FILE_SHARING ? 0 : state.unreadFilesCount
};
case SET_CHAT_WIDTH: {
return {
...state,
width: {
...state.width,
current: action.width
}
};
}
case SET_USER_CHAT_WIDTH: {
const { width } = action;
return {
...state,
width: {
current: width,
userSet: width
}
};
}
case SET_CHAT_IS_RESIZING: {
return {
...state,
isResizing: action.resizing
};
}
case NOTIFY_PRIVATE_RECIPIENTS_CHANGED:
return {
...state,
notifyPrivateRecipientsChangedTimestamp: action.payload
};
case ADD_FILE:
return {
...state,
unreadFilesCount: action.shouldIncrementUnread ? state.unreadFilesCount + 1 : state.unreadFilesCount
};
case _FILE_LIST_RECEIVED: {
const remoteFilesCount = Object.values(action.files).filter(
(file: any) => file.authorParticipantId !== action.localParticipantId
).length;
return {
...state,
unreadFilesCount: remoteFilesCount
};
}
}
return state;
});

View File

@@ -0,0 +1,23 @@
export const smileys = {
smiley1: ':)',
smiley2: ':(',
smiley3: ':D',
smiley4: ':+1:',
smiley5: ':P',
smiley6: ':wave:',
smiley7: ':blush:',
smiley8: ':slightly_smiling_face:',
smiley9: ':scream:',
smiley10: ':*',
smiley11: ':-1:',
smiley12: ':mag:',
smiley13: ':heart:',
smiley14: ':innocent:',
smiley15: ':angry:',
smiley16: ':angel:',
smiley17: ';(',
smiley18: ':clap:',
smiley19: ';)',
smiley20: ':beer:',
smiley21: ':moneybag:'
};

View File

@@ -0,0 +1,7 @@
/**
* The name of the bundled audio file which will be played for the incoming chat
* message sound.
*
* @type {string}
*/
export const INCOMING_MSG_SOUND_FILE = 'incomingMessage.mp3';

View File

View File

@@ -0,0 +1,73 @@
// @ts-ignore
import VideoLayout from '../../../modules/UI/videolayout/VideoLayout';
import StateListenerRegistry from '../base/redux/StateListenerRegistry';
import { clientResized } from '../base/responsive-ui/actions';
import { setChatWidth } from './actions.web';
import { CHAT_SIZE } from './constants';
import { getChatMaxSize } from './functions';
// import { setChatWidth } from './actions.web';
interface IListenerState {
clientWidth: number;
isOpen: boolean;
maxWidth: number;
width: {
current: number;
userSet: number | null;
};
}
/**
* Listens for changes in the client width to determine when to resize the chat panel.
*/
StateListenerRegistry.register(
/* selector */ state => {
return {
clientWidth: state['features/base/responsive-ui']?.clientWidth,
isOpen: state['features/chat'].isOpen,
width: state['features/chat'].width,
maxWidth: getChatMaxSize(state)
};
},
/* listener */ (
currentState: IListenerState,
{ dispatch },
previousState: IListenerState
) => {
if (currentState.isOpen
&& (currentState.clientWidth !== previousState.clientWidth
|| currentState.width !== previousState.width)) {
const { userSet = 0 } = currentState.width;
const { maxWidth } = currentState;
let chatPanelWidthChanged = false;
if (currentState.clientWidth !== previousState.clientWidth) {
if (userSet !== null) {
// if userSet is set, we need to check if it is within the bounds and potentially adjust it.
// This is in the case when screen gets smaller and the user set width is more than the maxWidth
// and we need to set it to the maxWidth. And also when the user set width has been larger than
// the maxWidth and we have reduced the current width to the maxWidth but now the screen gets bigger
// and we can increase the current width.
dispatch(setChatWidth(Math.max(Math.min(maxWidth, userSet), CHAT_SIZE)));
chatPanelWidthChanged = true;
} // else { // when userSet is null:
// no-op. The chat panel width will be the default one which is the min too.
// }
} else { // currentState.width !== previousState.width
chatPanelWidthChanged = true;
}
if (chatPanelWidthChanged) {
const { innerWidth, innerHeight } = window;
// Since the videoSpaceWidth relies on the chat panel width, we need to adjust it when the chat panel size changes
dispatch(clientResized(innerWidth, innerHeight));
}
// Recompute the large video size when chat is open and window resizes
VideoLayout.onResize();
}
});

View File

@@ -0,0 +1,134 @@
import { WithTranslation } from 'react-i18next';
import { IStore } from '../app/types';
import { IFileMetadata } from '../file-sharing/types';
import {
MESSAGE_TYPE_ERROR,
MESSAGE_TYPE_LOCAL,
MESSAGE_TYPE_REMOTE
} from './constants';
export type ChatMessageType =
| typeof MESSAGE_TYPE_LOCAL
| typeof MESSAGE_TYPE_ERROR
| typeof MESSAGE_TYPE_REMOTE;
export interface IMessage {
displayName: string;
editedAt?: number;
error?: unknown;
fileMetadata?: IFileMetadata;
isDeleted?: boolean;
isEdited?: Boolean;
isFromGuest?: boolean;
isFromVisitor?: boolean;
isModerated?: boolean;
isReaction: boolean;
lobbyChat: boolean;
message: string;
messageId: string;
messageType: ChatMessageType;
moderationReason?: string;
participantId: string;
privateMessage: boolean;
reactions: Map<string, Set<string>>;
recipient: string;
recipientId?: string;
/**
* When set, XMPP message id of the message this one replies to (XEP-0461), from lib-jitsi-meet.
*/
replyToMessageId?: string;
retractedBy?: string;
sentToVisitor?: boolean;
timestamp: number;
}
/**
* The type of the React {@code Component} props of {@code AbstractChat}.
*/
export interface IChatProps extends WithTranslation {
/**
* All the chat messages in the conference.
*/
_messages: IMessage[];
/**
* Number of unread chat messages.
*/
_unreadMessagesCount: number;
/**
* The Redux dispatch function.
*/
dispatch: IStore['dispatch'];
}
export interface IChatMessageProps extends WithTranslation {
/**
* Whether the message can be replied to.
*/
canReply?: boolean;
/**
* Whether gifs are enabled or not.
*/
gifEnabled?: boolean;
/**
* Case-insensitive search query whose matches in the message text should be highlighted.
* Left undefined/empty for no highlighting.
*/
highlightQuery?: string;
/**
* Whether this message is the currently focused search match (for prev/next navigation).
*/
isActiveMatch?: boolean;
/**
* Whether current participant is currently knocking in the lobby room.
*/
knocking?: boolean;
/**
* The representation of a chat message.
*/
message: IMessage;
/**
* Whether or not the avatar image of the participant which sent the message
* should be displayed.
*/
showAvatar?: boolean;
/**
* Whether or not the name of the participant which sent the message should
* be displayed.
*/
showDisplayName: boolean;
/**
* Whether or not the time at which the message was sent should be
* displayed.
*/
showTimestamp: boolean;
}
/**
* The shape of a single cached pending edit, keyed by messageId in Redux state.
*/
export interface IPendingEdit {
editedAt: number;
message: string;
participantId?: string;
}
/**
* The shape of the pendingEdits map in Redux state.
*/
export interface IPendingEditsMap {
[messageId: string]: IPendingEdit;
}