This commit is contained in:
174
react/features/chat/actionTypes.ts
Normal file
174
react/features/chat/actionTypes.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* The type of the action which signals to add a new chat message.
|
||||
*
|
||||
* {
|
||||
* type: ADD_MESSAGE,
|
||||
* displayName: string
|
||||
* hasRead: boolean,
|
||||
* id: string,
|
||||
* messageType: string,
|
||||
* 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 the action which signals to clear messages in Redux.
|
||||
*
|
||||
* {
|
||||
* type: CLEAR_MESSAGES
|
||||
* }
|
||||
*/
|
||||
export const CLEAR_MESSAGES = 'CLEAR_MESSAGES';
|
||||
|
||||
/**
|
||||
* 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 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 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 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 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';
|
||||
359
react/features/chat/actions.any.ts
Normal file
359
react/features/chat/actions.any.ts
Normal file
@@ -0,0 +1,359 @@
|
||||
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_MESSAGES,
|
||||
CLOSE_CHAT,
|
||||
EDIT_MESSAGE,
|
||||
NOTIFY_PRIVATE_RECIPIENTS_CHANGED,
|
||||
OPEN_CHAT,
|
||||
REMOVE_LOBBY_CHAT_PARTICIPANT,
|
||||
SEND_MESSAGE,
|
||||
SEND_REACTION,
|
||||
SET_FOCUSED_TAB,
|
||||
SET_LOBBY_CHAT_ACTIVE_STATE,
|
||||
SET_LOBBY_CHAT_RECIPIENT,
|
||||
SET_PRIVATE_MESSAGE_RECIPIENT
|
||||
} from './actionTypes';
|
||||
import { ChatTabs } from './constants';
|
||||
|
||||
/**
|
||||
* 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: string,
|
||||
* 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 messages in Redux.
|
||||
*
|
||||
* @returns {{
|
||||
* type: CLEAR_MESSAGES
|
||||
* }}
|
||||
*/
|
||||
export function clearMessages() {
|
||||
return {
|
||||
type: CLEAR_MESSAGES
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 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);
|
||||
};
|
||||
}
|
||||
31
react/features/chat/actions.native.ts
Normal file
31
react/features/chat/actions.native.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
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';
|
||||
|
||||
export * from './actions.any';
|
||||
|
||||
/**
|
||||
* Displays the chat panel.
|
||||
*
|
||||
* @param {Object} participant - The recipient for the private chat.
|
||||
* @param {boolean} disablePolls - Checks if polls are disabled.
|
||||
*
|
||||
* @returns {{
|
||||
* participant: participant,
|
||||
* type: OPEN_CHAT
|
||||
* }}
|
||||
*/
|
||||
export function openChat(participant: IParticipant | undefined | Object, disablePolls?: boolean) {
|
||||
if (disablePolls) {
|
||||
navigate(screen.conference.chat);
|
||||
}
|
||||
navigate(screen.conference.chatandpolls.main);
|
||||
|
||||
return {
|
||||
participant,
|
||||
type: OPEN_CHAT
|
||||
};
|
||||
}
|
||||
97
react/features/chat/actions.web.ts
Normal file
97
react/features/chat/actions.web.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
// @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 } from './actions.any';
|
||||
|
||||
export * from './actions.any';
|
||||
|
||||
/**
|
||||
* Displays the chat panel.
|
||||
*
|
||||
* @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({
|
||||
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
|
||||
};
|
||||
}
|
||||
131
react/features/chat/components/AbstractChatPrivacyDialog.tsx
Normal file
131
react/features/chat/components/AbstractChatPrivacyDialog.tsx
Normal 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)
|
||||
};
|
||||
}
|
||||
65
react/features/chat/components/AbstractMessageContainer.ts
Normal file
65
react/features/chat/components/AbstractMessageContainer.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
99
react/features/chat/components/AbstractMessageRecipient.ts
Normal file
99
react/features/chat/components/AbstractMessageRecipient.ts
Normal 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 { getVisitorDisplayName } 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
|
||||
? getVisitorDisplayName(state, privateMessageRecipient.name)
|
||||
: getParticipantDisplayName(state, privateMessageRecipient.id);
|
||||
}
|
||||
|
||||
return {
|
||||
_privateMessageRecipient,
|
||||
_isVisitor,
|
||||
_isLobbyChatActive: isLobbyChatActive,
|
||||
_lobbyMessageRecipient:
|
||||
isLobbyChatActive && lobbyMessageRecipient ? lobbyMessageRecipient.name : undefined,
|
||||
_visible: isLobbyChatActive ? isLocalParticipantModerator(state) : true
|
||||
};
|
||||
}
|
||||
2
react/features/chat/components/index.native.ts
Normal file
2
react/features/chat/components/index.native.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// @ts-ignore
|
||||
export { default as ChatPrivacyDialog } from './native/ChatPrivacyDialog';
|
||||
1
react/features/chat/components/index.web.ts
Normal file
1
react/features/chat/components/index.web.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as ChatPrivacyDialog } from './web/ChatPrivacyDialog';
|
||||
136
react/features/chat/components/native/Chat.tsx
Normal file
136
react/features/chat/components/native/Chat.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
/* eslint-disable react/no-multi-comp */
|
||||
import { Route, useIsFocused } from '@react-navigation/native';
|
||||
import React, { Component, useEffect } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { IReduxState } from '../../../app/types';
|
||||
import { translate } from '../../../base/i18n/functions';
|
||||
import JitsiScreen from '../../../base/modal/components/JitsiScreen';
|
||||
import { TabBarLabelCounter } from '../../../mobile/navigation/components/TabBarLabelCounter';
|
||||
import { closeChat, sendMessage } from '../../actions.native';
|
||||
import { IChatProps as AbstractProps } from '../../types';
|
||||
|
||||
import ChatInputBar from './ChatInputBar';
|
||||
import MessageContainer from './MessageContainer';
|
||||
import MessageRecipient from './MessageRecipient';
|
||||
import styles from './styles';
|
||||
|
||||
interface IProps extends AbstractProps {
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#render()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
override render() {
|
||||
const { _messages, route } = this.props;
|
||||
const privateMessageRecipient = route?.params?.privateMessageRecipient;
|
||||
|
||||
return (
|
||||
<JitsiScreen
|
||||
disableForcedKeyboardDismiss = { true }
|
||||
|
||||
/* eslint-disable react/jsx-no-bind */
|
||||
footerComponent = { () =>
|
||||
<ChatInputBar onSend = { this._onSendMessage } />
|
||||
}
|
||||
hasBottomTextInput = { true }
|
||||
hasExtraHeaderHeight = { true }
|
||||
style = { styles.chatContainer }>
|
||||
{/* @ts-ignore */}
|
||||
<MessageContainer messages = { _messages } />
|
||||
<MessageRecipient privateMessageRecipient = { privateMessageRecipient } />
|
||||
</JitsiScreen>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>,
|
||||
* _nbUnreadMessages: number
|
||||
* }}
|
||||
*/
|
||||
function _mapStateToProps(state: IReduxState, _ownProps: any) {
|
||||
const { messages, nbUnreadMessages } = state['features/chat'];
|
||||
|
||||
return {
|
||||
_messages: messages,
|
||||
_nbUnreadMessages: nbUnreadMessages
|
||||
};
|
||||
}
|
||||
|
||||
export default translate(connect(_mapStateToProps)((props: IProps) => {
|
||||
const { _nbUnreadMessages, dispatch, navigation, t } = props;
|
||||
const unreadMessagesNr = _nbUnreadMessages > 0;
|
||||
|
||||
const isFocused = useIsFocused();
|
||||
|
||||
useEffect(() => {
|
||||
navigation?.setOptions({
|
||||
tabBarLabel: () => (
|
||||
<TabBarLabelCounter
|
||||
activeUnreadNr = { unreadMessagesNr }
|
||||
isFocused = { isFocused }
|
||||
label = { t('chat.tabs.chat') }
|
||||
nbUnread = { _nbUnreadMessages } />
|
||||
)
|
||||
});
|
||||
|
||||
return () => {
|
||||
isFocused && dispatch(closeChat());
|
||||
};
|
||||
}, [ isFocused, _nbUnreadMessages ]);
|
||||
|
||||
return (
|
||||
<Chat { ...props } />
|
||||
);
|
||||
}));
|
||||
80
react/features/chat/components/native/ChatButton.ts
Normal file
80
react/features/chat/components/native/ChatButton.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
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 } 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.chatandpolls.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);
|
||||
const { visible = enabled } = ownProps;
|
||||
|
||||
return {
|
||||
_isPollsDisabled: arePollsDisabled(state),
|
||||
|
||||
// The toggled icon should also be available for new polls
|
||||
_unreadMessageCount: getUnreadCount(state) || getUnreadPollCount(state),
|
||||
visible
|
||||
};
|
||||
}
|
||||
|
||||
export default translate(connect(_mapStateToProps)(ChatButton));
|
||||
209
react/features/chat/components/native/ChatInputBar.tsx
Normal file
209
react/features/chat/components/native/ChatInputBar.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
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 { ASPECT_RATIO_WIDE } from '../../../base/responsive-ui/constants';
|
||||
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() {
|
||||
let inputBarStyles;
|
||||
|
||||
if (this.props.aspectRatio === ASPECT_RATIO_WIDE) {
|
||||
inputBarStyles = styles.inputBarWide;
|
||||
} else {
|
||||
inputBarStyles = styles.inputBarNarrow;
|
||||
}
|
||||
|
||||
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 = { [
|
||||
inputBarStyles,
|
||||
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 }
|
||||
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));
|
||||
244
react/features/chat/components/native/ChatMessage.tsx
Normal file
244
react/features/chat/components/native/ChatMessage.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
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 {
|
||||
getCanReplyToMessage,
|
||||
getFormattedTimestamp,
|
||||
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 } = 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);
|
||||
}
|
||||
|
||||
const messageText = 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>
|
||||
{ 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, t } = this.props;
|
||||
|
||||
if (!showDisplayName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { displayName, isFromVisitor } = message;
|
||||
|
||||
return (
|
||||
<Text style = { styles.senderDisplayName }>
|
||||
{ `${displayName}${isFromVisitor ? ` ${t('visitors.chatIndicator')}` : ''}` }
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the message text based on number of characters.
|
||||
*
|
||||
* @param {string} messageText - The message text.
|
||||
* @returns {React.ReactElement<*>}
|
||||
*/
|
||||
_renderMessageTextComponent(messageText: string) {
|
||||
|
||||
if (messageText.length >= CHAR_LIMIT) {
|
||||
return (
|
||||
<Text
|
||||
selectable = { true }
|
||||
style = { styles.chatMessage }>
|
||||
{ messageText }
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Linkify
|
||||
linkStyle = { styles.chatLink }
|
||||
style = { styles.chatMessage }>
|
||||
{ replaceNonUnicodeEmojis(messageText) }
|
||||
</Linkify>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
return {
|
||||
canReply: getCanReplyToMessage(state, message),
|
||||
gifEnabled: isGifEnabled(state),
|
||||
knocking: state['features/lobby'].knocking
|
||||
};
|
||||
}
|
||||
|
||||
export default translate(connect(_mapStateToProps)(ChatMessage));
|
||||
81
react/features/chat/components/native/ChatMessageGroup.tsx
Normal file
81
react/features/chat/components/native/ChatMessageGroup.tsx
Normal 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 } />
|
||||
);
|
||||
}
|
||||
}
|
||||
30
react/features/chat/components/native/ChatPrivacyDialog.tsx
Normal file
30
react/features/chat/components/native/ChatPrivacyDialog.tsx
Normal 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));
|
||||
28
react/features/chat/components/native/GifMessage.tsx
Normal file
28
react/features/chat/components/native/GifMessage.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import { Image, ImageStyle, View } from 'react-native';
|
||||
|
||||
import { 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);
|
||||
|
||||
return (<View
|
||||
id = 'gif-message'
|
||||
style = { styles.gifContainer }>
|
||||
<Image
|
||||
source = {{ uri: url }}
|
||||
style = { styles.gifImage as ImageStyle } />
|
||||
</View>);
|
||||
};
|
||||
|
||||
export default GifMessage;
|
||||
117
react/features/chat/components/native/MessageContainer.tsx
Normal file
117
react/features/chat/components/native/MessageContainer.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import React, { Component } from 'react';
|
||||
import { FlatList, Text, TextStyle, View, ViewStyle } from 'react-native';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { translate } from '../../../base/i18n/functions';
|
||||
import { IMessageGroup, groupMessagesBySender } from '../../../base/util/messageGrouping';
|
||||
import { IMessage } from '../../types';
|
||||
|
||||
import ChatMessageGroup from './ChatMessageGroup';
|
||||
import styles from './styles';
|
||||
|
||||
interface IProps {
|
||||
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[]
|
||||
};
|
||||
|
||||
/**
|
||||
* Instantiates a new instance of the component.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this._keyExtractor = this._keyExtractor.bind(this);
|
||||
this._renderListEmptyComponent = this._renderListEmptyComponent.bind(this);
|
||||
this._renderMessageGroup = this._renderMessageGroup.bind(this);
|
||||
this._getMessagesGroupedBySender = this._getMessagesGroupedBySender.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements {@code Component#render}.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
override render() {
|
||||
const data = this._getMessagesGroupedBySender();
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
ListEmptyComponent = { this._renderListEmptyComponent }
|
||||
bounces = { false }
|
||||
data = { data }
|
||||
|
||||
// Workaround for RN bug:
|
||||
// https://github.com/facebook/react-native/issues/21196
|
||||
inverted = { Boolean(data.length) }
|
||||
keyExtractor = { this._keyExtractor }
|
||||
keyboardShouldPersistTaps = 'handled'
|
||||
renderItem = { this._renderMessageGroup } />
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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 {Array<Object>} messages - The chat message to render.
|
||||
* @returns {React$Element<*>}
|
||||
*/
|
||||
_renderMessageGroup({ item: group }: { item: IMessageGroup<IMessage>; }) {
|
||||
const { messages } = group;
|
||||
|
||||
return <ChatMessageGroup messages = { messages } />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
|
||||
export default translate(connect()(MessageContainer));
|
||||
163
react/features/chat/components/native/MessageRecipient.tsx
Normal file
163
react/features/chat/components/native/MessageRecipient.tsx
Normal 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));
|
||||
110
react/features/chat/components/native/PrivateMessageButton.tsx
Normal file
110
react/features/chat/components/native/PrivateMessageButton.tsx
Normal 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.chatandpolls.main, {
|
||||
screen: screen.conference.chatandpolls.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));
|
||||
272
react/features/chat/components/native/styles.ts
Normal file
272
react/features/chat/components/native/styles.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
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]
|
||||
};
|
||||
|
||||
const inputBar = {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between'
|
||||
};
|
||||
|
||||
/**
|
||||
* 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: {
|
||||
color: BaseTheme.palette.text03,
|
||||
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'
|
||||
},
|
||||
|
||||
/**
|
||||
* 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: {
|
||||
alignSelf: 'center',
|
||||
flex: 1,
|
||||
padding: BoxModel.padding,
|
||||
paddingTop: '8%',
|
||||
maxWidth: '80%'
|
||||
},
|
||||
|
||||
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: 30
|
||||
},
|
||||
|
||||
inputBarNarrow: {
|
||||
...inputBar,
|
||||
height: 112,
|
||||
marginHorizontal: BaseTheme.spacing[3]
|
||||
},
|
||||
|
||||
inputBarWide: {
|
||||
...inputBar,
|
||||
height: 88,
|
||||
marginHorizontal: BaseTheme.spacing[9]
|
||||
},
|
||||
|
||||
customInputContainer: {
|
||||
width: '75%'
|
||||
},
|
||||
|
||||
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
|
||||
},
|
||||
|
||||
chatContainer: {
|
||||
backgroundColor: BaseTheme.palette.ui01,
|
||||
flex: 1
|
||||
},
|
||||
|
||||
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: {
|
||||
resizeMode: 'contain',
|
||||
width: 250,
|
||||
height: undefined,
|
||||
flexGrow: 1
|
||||
},
|
||||
|
||||
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
|
||||
}
|
||||
};
|
||||
645
react/features/chat/components/web/Chat.tsx
Normal file
645
react/features/chat/components/web/Chat.tsx
Normal file
@@ -0,0 +1,645 @@
|
||||
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 { translate } from '../../../base/i18n/functions';
|
||||
import { IconInfo, IconMessage, IconShareDoc, IconSubtitles } from '../../../base/icons/svg';
|
||||
import { getLocalParticipant, getRemoteParticipants } 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,
|
||||
setChatIsResizing,
|
||||
setFocusedTab,
|
||||
setPrivateMessageRecipient,
|
||||
setPrivateMessageRecipientById,
|
||||
setUserChatWidth,
|
||||
toggleChat
|
||||
} from '../../actions.web';
|
||||
import { CHAT_SIZE, ChatTabs, OPTION_GROUPCHAT, SMALL_WIDTH_THRESHOLD } from '../../constants';
|
||||
import { getChatMaxSize } from '../../functions';
|
||||
import { IChatProps as AbstractProps } 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 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;
|
||||
|
||||
/**
|
||||
* Number of unread poll messages.
|
||||
*/
|
||||
_nbUnreadPolls: number;
|
||||
|
||||
/**
|
||||
* Function to send a text message.
|
||||
*
|
||||
* @protected
|
||||
*/
|
||||
_onSendMessage: Function;
|
||||
|
||||
/**
|
||||
* Function to toggle the chat window.
|
||||
*/
|
||||
_onToggleChat: Function;
|
||||
|
||||
/**
|
||||
* Function to display the chat tab.
|
||||
*
|
||||
* @protected
|
||||
*/
|
||||
_onToggleChatTab: Function;
|
||||
|
||||
/**
|
||||
* Function to display the polls tab.
|
||||
*
|
||||
* @protected
|
||||
*/
|
||||
_onTogglePollsTab: Function;
|
||||
|
||||
/**
|
||||
* Whether or not to block chat access with a nickname input form.
|
||||
*/
|
||||
_showNamePrompt: boolean;
|
||||
|
||||
/**
|
||||
* The current width of the chat panel.
|
||||
*/
|
||||
_width: number;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles<{ _isResizing: boolean; width: number; }>()((theme, { _isResizing, width }) => {
|
||||
return {
|
||||
container: {
|
||||
backgroundColor: theme.palette.ui01,
|
||||
flexShrink: 0,
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
transition: _isResizing ? undefined : 'width .16s ease-in-out',
|
||||
width: `${width}px`,
|
||||
zIndex: 300,
|
||||
|
||||
'&:hover, &:focus-within': {
|
||||
'& .dragHandleContainer': {
|
||||
visibility: 'visible'
|
||||
}
|
||||
},
|
||||
|
||||
'@media (max-width: 580px)': {
|
||||
height: '100dvh',
|
||||
position: 'fixed',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
width: 'auto'
|
||||
},
|
||||
|
||||
'*': {
|
||||
userSelect: 'text',
|
||||
'-webkit-user-select': '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.text01,
|
||||
...theme.typography.heading6,
|
||||
lineHeight: 'unset',
|
||||
fontWeight: theme.typography.heading6.fontWeight as any,
|
||||
|
||||
'.jitsi-icon': {
|
||||
cursor: 'pointer'
|
||||
}
|
||||
},
|
||||
|
||||
chatPanel: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
|
||||
// extract header + tabs height
|
||||
height: 'calc(100% - 110px)'
|
||||
},
|
||||
|
||||
chatPanelNoTabs: {
|
||||
// extract header height
|
||||
height: 'calc(100% - 60px)'
|
||||
},
|
||||
|
||||
pollsPanel: {
|
||||
// extract header + tabs height
|
||||
height: 'calc(100% - 110px)'
|
||||
},
|
||||
|
||||
resizableChat: {
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: '100%'
|
||||
},
|
||||
|
||||
dragHandleContainer: {
|
||||
height: '100%',
|
||||
width: '9px',
|
||||
backgroundColor: 'transparent',
|
||||
position: 'absolute',
|
||||
cursor: 'col-resize',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
visibility: 'hidden',
|
||||
right: '4px',
|
||||
top: 0,
|
||||
|
||||
'&:hover': {
|
||||
'& .dragHandle': {
|
||||
backgroundColor: theme.palette.icon01
|
||||
}
|
||||
},
|
||||
|
||||
'&.visible': {
|
||||
visibility: 'visible',
|
||||
|
||||
'& .dragHandle': {
|
||||
backgroundColor: theme.palette.icon01
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
dragHandle: {
|
||||
backgroundColor: theme.palette.icon02,
|
||||
height: '100px',
|
||||
width: '3px',
|
||||
borderRadius: '1px'
|
||||
},
|
||||
|
||||
privateMessageRecipientsList: {
|
||||
padding: '0 16px 5px'
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const Chat = ({
|
||||
_isModal,
|
||||
_isOpen,
|
||||
_isPollsEnabled,
|
||||
_isCCTabEnabled,
|
||||
_isFileSharingTabEnabled,
|
||||
_focusedTab,
|
||||
_isResizing,
|
||||
_messages,
|
||||
_nbUnreadMessages,
|
||||
_nbUnreadPolls,
|
||||
_onSendMessage,
|
||||
_onToggleChat,
|
||||
_onToggleChatTab,
|
||||
_onTogglePollsTab,
|
||||
_showNamePrompt,
|
||||
_width,
|
||||
dispatch,
|
||||
t
|
||||
}: IProps) => {
|
||||
const { classes, cx } = useStyles({ _isResizing, width: _width });
|
||||
const [ isMouseDown, setIsMouseDown ] = useState(false);
|
||||
const [ mousePosition, setMousePosition ] = useState<number | null>(null);
|
||||
const [ dragChatWidth, setDragChatWidth ] = useState<number | null>(null);
|
||||
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 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 mouse down on the drag handle.
|
||||
*
|
||||
* @param {MouseEvent} e - The mouse down event.
|
||||
* @returns {void}
|
||||
*/
|
||||
const onDragHandleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Store the initial mouse 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
|
||||
document.body.style.cursor = 'col-resize';
|
||||
|
||||
// Disable text selection during resize
|
||||
document.body.style.userSelect = 'none';
|
||||
|
||||
console.log('Chat resize: Mouse down', { clientX: e.clientX, initialWidth: _width });
|
||||
}, [ _width, dispatch ]);
|
||||
|
||||
/**
|
||||
* Drag handle mouse up handler.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
const onDragMouseUp = useCallback(() => {
|
||||
if (isMouseDown) {
|
||||
setIsMouseDown(false);
|
||||
dispatch(setChatIsResizing(false));
|
||||
|
||||
// Restore cursor and text selection
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
|
||||
console.log('Chat resize: Mouse up');
|
||||
}
|
||||
}, [ isMouseDown, dispatch ]);
|
||||
|
||||
/**
|
||||
* Handles drag handle mouse move.
|
||||
*
|
||||
* @param {MouseEvent} e - The mousemove event.
|
||||
* @returns {void}
|
||||
*/
|
||||
const onChatResize = useCallback(throttle((e: MouseEvent) => {
|
||||
// console.log('Chat resize: Mouse move', { clientX: e.clientX, isMouseDown, mousePosition, _width });
|
||||
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('mouseup', onDragMouseUp);
|
||||
document.addEventListener('mousemove', onChatResize);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mouseup', onDragMouseUp);
|
||||
document.removeEventListener('mousemove', onChatResize);
|
||||
};
|
||||
}, [ onDragMouseUp, onChatResize ]);
|
||||
|
||||
/**
|
||||
* Sends a text message.
|
||||
*
|
||||
* @private
|
||||
* @param {string} text - The text message to be sent.
|
||||
* @returns {void}
|
||||
* @type {Function}
|
||||
*/
|
||||
const onSendMessage = useCallback((text: string) => {
|
||||
dispatch(sendMessage(text));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 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()}
|
||||
<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
|
||||
messages = { _messages } />
|
||||
<MessageRecipient />
|
||||
<Select
|
||||
containerClassName = { cx(classes.privateMessageRecipientsList) }
|
||||
id = 'select-chat-recipient'
|
||||
onChange = { onSelectedRecipientChange }
|
||||
options = { options }
|
||||
value = { privateMessageRecipient?.id || OPTION_GROUPCHAT } />
|
||||
<ChatInput
|
||||
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() {
|
||||
let tabs = [
|
||||
{
|
||||
accessibilityLabel: t('chat.tabs.chat'),
|
||||
countBadge:
|
||||
_focusedTab !== ChatTabs.CHAT && _nbUnreadMessages > 0 ? _nbUnreadMessages : 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 && _nbUnreadPolls > 0 ? _nbUnreadPolls : 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: 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 } />
|
||||
);
|
||||
}
|
||||
|
||||
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 }
|
||||
isPollsEnabled = { _isPollsEnabled } />
|
||||
: renderChat()}
|
||||
<div
|
||||
className = { cx(
|
||||
classes.dragHandleContainer,
|
||||
(isMouseDown || _isResizing) && 'visible',
|
||||
'dragHandleContainer'
|
||||
) }
|
||||
onMouseDown = { onDragHandleMouseDown }>
|
||||
<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>,
|
||||
* _nbUnreadMessages: number,
|
||||
* _nbUnreadPolls: number,
|
||||
* _showNamePrompt: boolean,
|
||||
* _width: number,
|
||||
* _isResizing: boolean
|
||||
* }}
|
||||
*/
|
||||
function _mapStateToProps(state: IReduxState, _ownProps: any) {
|
||||
const { isOpen, focusedTab, messages, nbUnreadMessages, width, isResizing } = state['features/chat'];
|
||||
const { nbUnreadPolls } = state['features/polls'];
|
||||
const _localParticipant = getLocalParticipant(state);
|
||||
|
||||
return {
|
||||
_isModal: window.innerWidth <= SMALL_WIDTH_THRESHOLD,
|
||||
_isOpen: isOpen,
|
||||
_isPollsEnabled: !arePollsDisabled(state),
|
||||
_isCCTabEnabled: isCCTabEnabled(state),
|
||||
_isFileSharingTabEnabled: isFileSharingEnabled(state),
|
||||
_focusedTab: focusedTab,
|
||||
_messages: messages,
|
||||
_nbUnreadMessages: nbUnreadMessages,
|
||||
_nbUnreadPolls: nbUnreadPolls,
|
||||
_showNamePrompt: !_localParticipant?.name,
|
||||
_width: width?.current || CHAT_SIZE,
|
||||
_isResizing: isResizing
|
||||
};
|
||||
}
|
||||
|
||||
export default translate(connect(_mapStateToProps)(Chat));
|
||||
98
react/features/chat/components/web/ChatButton.tsx
Normal file
98
react/features/chat/components/web/ChatButton.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
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 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
|
||||
};
|
||||
};
|
||||
|
||||
export default translate(connect(mapStateToProps)(ChatButton));
|
||||
74
react/features/chat/components/web/ChatCounter.tsx
Normal file
74
react/features/chat/components/web/ChatCounter.tsx
Normal 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 } 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),
|
||||
_isOpen: isOpen
|
||||
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(_mapStateToProps)(ChatCounter);
|
||||
88
react/features/chat/components/web/ChatHeader.tsx
Normal file
88
react/features/chat/components/web/ChatHeader.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { IReduxState } from '../../../app/types';
|
||||
import Icon from '../../../base/icons/components/Icon';
|
||||
import { IconCloseLarge } from '../../../base/icons/svg';
|
||||
import { isFileSharingEnabled } from '../../../file-sharing/functions.any';
|
||||
import { toggleChat } from '../../actions.web';
|
||||
import { ChatTabs } from '../../constants';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom header of the {@code ChatDialog}.
|
||||
*
|
||||
* @returns {React$Element<any>}
|
||||
*/
|
||||
function ChatHeader({ className, isCCTabEnabled, isPollsEnabled }: IProps) {
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
const { focusedTab } = useSelector((state: IReduxState) => state['features/chat']);
|
||||
const fileSharingTabEnabled = useSelector(isFileSharingEnabled);
|
||||
|
||||
const onCancel = useCallback(() => {
|
||||
dispatch(toggleChat());
|
||||
}, []);
|
||||
|
||||
const onKeyPressHandler = useCallback(e => {
|
||||
if (onCancel && (e.key === ' ' || e.key === 'Enter')) {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
}, []);
|
||||
|
||||
let title = 'chat.title';
|
||||
|
||||
if (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';
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className = { className || 'chat-dialog-header' }>
|
||||
<span
|
||||
aria-level = { 1 }
|
||||
role = 'heading'>
|
||||
{ t(title) }
|
||||
</span>
|
||||
<Icon
|
||||
ariaLabel = { t('toolbar.closeChat') }
|
||||
onClick = { onCancel }
|
||||
onKeyPress = { onKeyPressHandler }
|
||||
role = 'button'
|
||||
src = { IconCloseLarge }
|
||||
tabIndex = { 0 } />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChatHeader;
|
||||
356
react/features/chat/components/web/ChatInput.tsx
Normal file
356
react/features/chat/components/web/ChatInput.tsx
Normal file
@@ -0,0 +1,356 @@
|
||||
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 { CHAT_SIZE } from '../../constants';
|
||||
import { areSmileysDisabled, isSendGroupChatDisabled } from '../../functions';
|
||||
|
||||
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.ui02}`,
|
||||
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'];
|
||||
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
|
||||
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 === '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 });
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)));
|
||||
447
react/features/chat/components/web/ChatMessage.tsx
Normal file
447
react/features/chat/components/web/ChatMessage.tsx
Normal file
@@ -0,0 +1,447 @@
|
||||
import { Theme } from '@mui/material';
|
||||
import React, { useCallback, useMemo, 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';
|
||||
import { getFormattedTimestamp, getMessageText, getPrivateNoticeMessage } from '../../functions';
|
||||
import { IChatMessageProps } from '../../types';
|
||||
|
||||
import MessageMenu from './MessageMenu';
|
||||
import ReactButton from './ReactButton';
|
||||
|
||||
interface IProps extends IChatMessageProps {
|
||||
className?: string;
|
||||
enablePrivateChat?: boolean;
|
||||
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.ui02,
|
||||
borderRadius: '4px 12px 12px 12px',
|
||||
maxWidth: '100%',
|
||||
marginTop: '4px',
|
||||
boxSizing: 'border-box' as const,
|
||||
|
||||
'&.privatemessage': {
|
||||
backgroundColor: theme.palette.support05
|
||||
},
|
||||
'&.local': {
|
||||
backgroundColor: theme.palette.ui04,
|
||||
borderRadius: '12px 4px 12px 12px',
|
||||
|
||||
'&.privatemessage': {
|
||||
backgroundColor: theme.palette.support05
|
||||
},
|
||||
'&.local': {
|
||||
backgroundColor: theme.palette.ui04,
|
||||
borderRadius: '12px 4px 12px 12px',
|
||||
|
||||
'&.privatemessage': {
|
||||
backgroundColor: theme.palette.support05
|
||||
}
|
||||
},
|
||||
|
||||
'&.error': {
|
||||
backgroundColor: theme.palette.actionDanger,
|
||||
borderRadius: 0,
|
||||
fontWeight: 100
|
||||
},
|
||||
|
||||
'&.lobbymessage': {
|
||||
backgroundColor: theme.palette.support05
|
||||
}
|
||||
},
|
||||
'&.error': {
|
||||
backgroundColor: theme.palette.actionDanger,
|
||||
borderRadius: 0,
|
||||
fontWeight: 100
|
||||
},
|
||||
'&.lobbymessage': {
|
||||
backgroundColor: theme.palette.support05
|
||||
}
|
||||
},
|
||||
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',
|
||||
flex: 1
|
||||
},
|
||||
optionsButtonContainer: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing(1),
|
||||
minWidth: '32px',
|
||||
minHeight: '32px'
|
||||
},
|
||||
displayName: {
|
||||
...theme.typography.labelBold,
|
||||
color: theme.palette.text02,
|
||||
whiteSpace: 'nowrap',
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
marginBottom: theme.spacing(1),
|
||||
maxWidth: '130px'
|
||||
},
|
||||
userMessage: {
|
||||
...theme.typography.bodyShortRegular,
|
||||
color: theme.palette.text01,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word'
|
||||
},
|
||||
privateMessageNotice: {
|
||||
...theme.typography.labelRegular,
|
||||
color: theme.palette.text02,
|
||||
marginTop: theme.spacing(1)
|
||||
},
|
||||
timestamp: {
|
||||
...theme.typography.labelRegular,
|
||||
color: theme.palette.text03,
|
||||
marginTop: theme.spacing(1),
|
||||
marginLeft: theme.spacing(1),
|
||||
whiteSpace: 'nowrap',
|
||||
flexShrink: 0
|
||||
},
|
||||
reactionsPopover: {
|
||||
padding: theme.spacing(2),
|
||||
backgroundColor: theme.palette.ui03,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
maxWidth: '150px',
|
||||
maxHeight: '400px',
|
||||
overflowY: 'auto',
|
||||
color: theme.palette.text01
|
||||
},
|
||||
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'
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const ChatMessage = ({
|
||||
className = '',
|
||||
message,
|
||||
state,
|
||||
showDisplayName,
|
||||
shouldDisplayMenuOnRight,
|
||||
enablePrivateChat,
|
||||
knocking,
|
||||
t
|
||||
}: IProps) => {
|
||||
const { classes, cx } = useStyles();
|
||||
const [ isHovered, setIsHovered ] = useState(false);
|
||||
const [ isReactionsOpen, setIsReactionsOpen ] = useState(false);
|
||||
|
||||
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, isFromVisitor = false } = message;
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden = { true }
|
||||
className = { cx('display-name', classes.displayName) }>
|
||||
{`${displayName}${isFromVisitor ? ` ${t('visitors.chatIndicator')}` : ''}`}
|
||||
</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 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 ]);
|
||||
|
||||
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 && <MessageMenu
|
||||
displayName = { message.displayName }
|
||||
enablePrivateChat = { Boolean(enablePrivateChat) }
|
||||
isFromVisitor = { message.isFromVisitor }
|
||||
isLobbyMessage = { message.lobbyChat }
|
||||
message = { message.message }
|
||||
participantId = { message.participantId } />}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className = { cx(
|
||||
'chatmessage',
|
||||
classes.chatMessage,
|
||||
className,
|
||||
message.privateMessage && 'privatemessage',
|
||||
message.lobbyChat && !knocking && 'lobbymessage'
|
||||
) }>
|
||||
<div className = { classes.replyWrapper }>
|
||||
<div className = { cx('messagecontent', classes.messageContent) }>
|
||||
{showDisplayName && _renderDisplayName()}
|
||||
<div className = { cx('usermessage', classes.userMessage) }>
|
||||
<Message
|
||||
screenReaderHelpText = { message.displayName === message.recipient
|
||||
? t<string>('chat.messageAccessibleTitleMe')
|
||||
: t<string>('chat.messageAccessibleTitle', {
|
||||
user: message.displayName
|
||||
}) }
|
||||
text = { getMessageText(message) } />
|
||||
{(message.privateMessage || (message.lobbyChat && !knocking))
|
||||
&& _renderPrivateNotice()}
|
||||
<div className = { classes.chatMessageFooter }>
|
||||
<div className = { classes.chatMessageFooterLeft }>
|
||||
{message.reactions && message.reactions.size > 0 && (
|
||||
<>
|
||||
{renderReactions}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{_renderTimestamp()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{shouldDisplayMenuOnRight && (
|
||||
<div className = { classes.sideBySideContainer }>
|
||||
{!message.privateMessage && !message.lobbyChat && <div>
|
||||
<div className = { classes.optionsButtonContainer }>
|
||||
{isHovered && <ReactButton
|
||||
messageId = { message.messageId }
|
||||
receiverId = { '' } />}
|
||||
</div>
|
||||
</div>}
|
||||
<div>
|
||||
<div className = { classes.optionsButtonContainer }>
|
||||
{isHovered && <MessageMenu
|
||||
displayName = { message.displayName }
|
||||
enablePrivateChat = { Boolean(enablePrivateChat) }
|
||||
isFromVisitor = { message.isFromVisitor }
|
||||
isLobbyMessage = { message.lobbyChat }
|
||||
message = { message.message }
|
||||
participantId = { message.participantId } />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</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 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,
|
||||
knocking,
|
||||
state
|
||||
};
|
||||
}
|
||||
|
||||
export default translate(connect(_mapStateToProps)(ChatMessage));
|
||||
85
react/features/chat/components/web/ChatMessageGroup.tsx
Normal file
85
react/features/chat/components/web/ChatMessageGroup.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
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;
|
||||
|
||||
/**
|
||||
* The messages to display as a group.
|
||||
*/
|
||||
messages: Array<IMessage>;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
messageGroup: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
maxWidth: '100%',
|
||||
|
||||
'&.remote': {
|
||||
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 = '', messages }: 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 }
|
||||
key = { i }
|
||||
message = { message }
|
||||
showDisplayName = { i === 0 }
|
||||
showTimestamp = { i === messages.length - 1 } />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatMessageGroup;
|
||||
34
react/features/chat/components/web/ChatPrivacyDialog.tsx
Normal file
34
react/features/chat/components/web/ChatPrivacyDialog.tsx
Normal 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));
|
||||
182
react/features/chat/components/web/ClosedCaptionsTab.tsx
Normal file
182
react/features/chat/components/web/ClosedCaptionsTab.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import React, { useCallback, useMemo, useState } 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 Icon from '../../../base/icons/components/Icon';
|
||||
import { IconSubtitles } from '../../../base/icons/svg';
|
||||
import Button from '../../../base/ui/components/web/Button';
|
||||
import { groupMessagesBySender } from '../../../base/util/messageGrouping';
|
||||
import { setRequestingSubtitles } from '../../../subtitles/actions.any';
|
||||
import LanguageSelector from '../../../subtitles/components/web/LanguageSelector';
|
||||
import { canStartSubtitles } from '../../../subtitles/functions.any';
|
||||
import { ISubtitle } from '../../../subtitles/types';
|
||||
import { isTranscribing } from '../../../transcribing/functions';
|
||||
|
||||
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.text01
|
||||
},
|
||||
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.text01,
|
||||
textAlign: 'center'
|
||||
},
|
||||
emptyIcon: {
|
||||
width: '100px',
|
||||
padding: '16px',
|
||||
|
||||
'& svg': {
|
||||
width: '100%',
|
||||
height: 'auto'
|
||||
}
|
||||
},
|
||||
emptyState: {
|
||||
...theme.typography.bodyLongBold,
|
||||
color: theme.palette.text02
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Component that displays the subtitles history in a scrollable list.
|
||||
*
|
||||
* @returns {JSX.Element} - The ClosedCaptionsTab component.
|
||||
*/
|
||||
export default function ClosedCaptionsTab() {
|
||||
const { classes, theme } = useStyles();
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
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 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 (isButtonPressed) {
|
||||
return;
|
||||
}
|
||||
dispatch(setRequestingSubtitles(true, false, null));
|
||||
setButtonPressed(true);
|
||||
}, [ dispatch, isButtonPressed, setButtonPressed ]);
|
||||
|
||||
if (subtitlesError && isButtonPressed) {
|
||||
setButtonPressed(false);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
if (isButtonPressed) {
|
||||
setButtonPressed(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className = { classes.emptyContent }>
|
||||
<Icon
|
||||
className = { classes.emptyIcon }
|
||||
color = { theme.palette.icon03 }
|
||||
src = { IconSubtitles } />
|
||||
<span className = { classes.emptyState }>
|
||||
{ t('closedCaptionsTab.emptyState') }
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isButtonPressed) {
|
||||
setButtonPressed(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className = { classes.container }>
|
||||
<LanguageSelector />
|
||||
<div className = { classes.messagesContainer }>
|
||||
<SubtitlesMessagesContainer
|
||||
groups = { groupedSubtitles }
|
||||
messages = { filteredSubtitles } />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
157
react/features/chat/components/web/DisplayNameForm.tsx
Normal file
157
react/features/chat/components/web/DisplayNameForm.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
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 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, isPollsEnabled, t } = this.props;
|
||||
|
||||
let title = 'chat.nickname.title';
|
||||
|
||||
if (isCCTabEnabled && isPollsEnabled) {
|
||||
title = 'chat.nickname.titleWithPollsAndCC';
|
||||
} else if (isCCTabEnabled) {
|
||||
title = 'chat.nickname.titleWithCC';
|
||||
} else if (isPollsEnabled) {
|
||||
title = 'chat.nickname.titleWithPolls';
|
||||
}
|
||||
|
||||
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));
|
||||
60
react/features/chat/components/web/EmojiSelector.tsx
Normal file
60
react/features/chat/components/web/EmojiSelector.tsx
Normal 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.ui03
|
||||
},
|
||||
|
||||
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;
|
||||
40
react/features/chat/components/web/GifMessage.tsx
Normal file
40
react/features/chat/components/web/GifMessage.tsx
Normal 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;
|
||||
54
react/features/chat/components/web/KeyboardAvoider.tsx
Normal file
54
react/features/chat/components/web/KeyboardAvoider.tsx
Normal 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;
|
||||
337
react/features/chat/components/web/MessageContainer.tsx
Normal file
337
react/features/chat/components/web/MessageContainer.tsx
Normal file
@@ -0,0 +1,337 @@
|
||||
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 {
|
||||
messages: IMessage[];
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
static defaultProps = {
|
||||
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>();
|
||||
|
||||
// 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 }
|
||||
key = { index }
|
||||
messages = { messages } />
|
||||
);
|
||||
});
|
||||
|
||||
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() {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
179
react/features/chat/components/web/MessageMenu.tsx
Normal file
179
react/features/chat/components/web/MessageMenu.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
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 } 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 { handleLobbyChatInitialized, openChat } from '../../actions.web';
|
||||
|
||||
export interface IProps {
|
||||
className?: string;
|
||||
displayName?: string;
|
||||
enablePrivateChat: boolean;
|
||||
isFromVisitor?: boolean;
|
||||
isLobbyMessage: boolean;
|
||||
message: string;
|
||||
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.ui03,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
boxShadow: theme.shadows[3],
|
||||
overflow: 'hidden'
|
||||
},
|
||||
copiedMessage: {
|
||||
position: 'fixed',
|
||||
backgroundColor: theme.palette.ui03,
|
||||
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 = ({ message, participantId, isFromVisitor, isLobbyMessage, enablePrivateChat, displayName }: 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 participant = useSelector((state: IReduxState) => getParticipantById(state, participantId));
|
||||
|
||||
const handleMenuClick = useCallback(() => {
|
||||
setIsPopoverOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setIsPopoverOpen(false);
|
||||
}, []);
|
||||
|
||||
const handlePrivateClick = useCallback(() => {
|
||||
if (isLobbyMessage) {
|
||||
dispatch(handleLobbyChatInitialized(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: participantId,
|
||||
name: displayName,
|
||||
isVisitor: true
|
||||
};
|
||||
|
||||
dispatch(openChat(visitorParticipant));
|
||||
} else {
|
||||
dispatch(openChat(participant));
|
||||
}
|
||||
}
|
||||
handleClose();
|
||||
}, [ dispatch, isLobbyMessage, participant, participantId, displayName ]);
|
||||
|
||||
const handleCopyClick = useCallback(() => {
|
||||
copyText(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 {
|
||||
console.error('Failed to copy text');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error copying text:', error);
|
||||
});
|
||||
handleClose();
|
||||
}, [ message ]);
|
||||
|
||||
const popoverContent = (
|
||||
<div className = { classes.menuPanel }>
|
||||
{enablePrivateChat && (
|
||||
<div
|
||||
className = { classes.menuItem }
|
||||
onClick = { handlePrivateClick }>
|
||||
{t('Private Message')}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className = { classes.menuItem }
|
||||
onClick = { handleCopyClick }>
|
||||
{t('Copy')}
|
||||
</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;
|
||||
99
react/features/chat/components/web/MessageRecipient.tsx
Normal file
99
react/features/chat/components/web/MessageRecipient.tsx
Normal 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.text01
|
||||
},
|
||||
|
||||
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);
|
||||
89
react/features/chat/components/web/NewMessagesButton.tsx
Normal file
89
react/features/chat/components/web/NewMessagesButton.tsx
Normal 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);
|
||||
74
react/features/chat/components/web/PrivateMessageButton.tsx
Normal file
74
react/features/chat/components/web/PrivateMessageButton.tsx
Normal 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;
|
||||
87
react/features/chat/components/web/ReactButton.tsx
Normal file
87
react/features/chat/components/web/ReactButton.tsx
Normal 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;
|
||||
120
react/features/chat/components/web/SmileysPanel.tsx
Normal file
120
react/features/chat/components/web/SmileysPanel.tsx
Normal 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;
|
||||
96
react/features/chat/components/web/SubtitleMessage.tsx
Normal file
96
react/features/chat/components/web/SubtitleMessage.tsx
Normal 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.ui02,
|
||||
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.text02,
|
||||
whiteSpace: 'nowrap',
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
marginBottom: theme.spacing(1),
|
||||
maxWidth: '130px'
|
||||
},
|
||||
|
||||
messageText: {
|
||||
...theme.typography.bodyShortRegular,
|
||||
color: theme.palette.text01,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word'
|
||||
},
|
||||
|
||||
timestamp: {
|
||||
...theme.typography.labelRegular,
|
||||
color: theme.palette.text03,
|
||||
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>
|
||||
);
|
||||
}
|
||||
76
react/features/chat/components/web/SubtitlesGroup.tsx
Normal file
76
react/features/chat/components/web/SubtitlesGroup.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
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;
|
||||
}>;
|
||||
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 }: 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 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(() => {
|
||||
scrollToElement(false, null);
|
||||
createBottomListObserver();
|
||||
|
||||
return () => {
|
||||
if (observer) {
|
||||
observer.disconnect();
|
||||
setObserver(null);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
59
react/features/chat/constants.ts
Normal file
59
react/features/chat/constants.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
/**
|
||||
* The {@code messageType} of local messages.
|
||||
*/
|
||||
export const MESSAGE_TYPE_LOCAL = 'local';
|
||||
|
||||
/**
|
||||
* The {@code messageType} of remote messages.
|
||||
*/
|
||||
export const MESSAGE_TYPE_REMOTE = 'remote';
|
||||
|
||||
export const SMALL_WIDTH_THRESHOLD = 580;
|
||||
|
||||
|
||||
/**
|
||||
* 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';
|
||||
267
react/features/chat/functions.ts
Normal file
267
react/features/chat/functions.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
// @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 { 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 { getParticipantsPaneWidth } from '../participants-pane/functions';
|
||||
import { VIDEO_SPACE_MIN_SIZE } from '../video-layout/constants';
|
||||
import { IVisitorChatParticipant } from '../visitors/types';
|
||||
|
||||
import { MESSAGE_TYPE_ERROR, MESSAGE_TYPE_LOCAL, TIMESTAMP_FORMAT } from './constants';
|
||||
import { IMessage } 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) {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
const disableChatSmileys = state['features/base/config']?.disableChatSmileys === true;
|
||||
|
||||
return disableChatSmileys;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
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) {
|
||||
return message.messageType === MESSAGE_TYPE_ERROR
|
||||
? i18next.t('chat.error', {
|
||||
error: message.message
|
||||
})
|
||||
: message.message;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns whether a message can be replied to.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @param {IMessage} message - The message to be checked.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function getCanReplyToMessage(state: IReduxState, message: IMessage) {
|
||||
const { knocking } = state['features/lobby'];
|
||||
const participant = getParticipantById(state, message.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) {
|
||||
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) {
|
||||
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) {
|
||||
const { clientWidth } = state['features/base/responsive-ui'];
|
||||
|
||||
return Math.max(clientWidth - getParticipantsPaneWidth(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);
|
||||
}
|
||||
752
react/features/chat/middleware.ts
Normal file
752
react/features/chat/middleware.ts
Normal file
@@ -0,0 +1,752 @@
|
||||
import { AnyAction } from 'redux';
|
||||
|
||||
import { IReduxState, IStore } from '../app/types';
|
||||
import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from '../base/app/actionTypes';
|
||||
import {
|
||||
CONFERENCE_JOINED,
|
||||
ENDPOINT_MESSAGE_RECEIVED,
|
||||
NON_PARTICIPANT_MESSAGE_RECEIVED
|
||||
} from '../base/conference/actionTypes';
|
||||
import { getCurrentConference } from '../base/conference/functions';
|
||||
import { IJitsiConference } from '../base/conference/reducer';
|
||||
import { openDialog } from '../base/dialog/actions';
|
||||
import i18next from '../base/i18n/i18next';
|
||||
import {
|
||||
JitsiConferenceErrors,
|
||||
JitsiConferenceEvents
|
||||
} from '../base/lib-jitsi-meet';
|
||||
import { PARTICIPANT_JOINED, PARTICIPANT_LEFT, PARTICIPANT_UPDATED } from '../base/participants/actionTypes';
|
||||
import {
|
||||
getLocalParticipant,
|
||||
getParticipantById,
|
||||
getParticipantDisplayName
|
||||
} from '../base/participants/functions';
|
||||
import { IParticipant } from '../base/participants/types';
|
||||
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
|
||||
import StateListenerRegistry from '../base/redux/StateListenerRegistry';
|
||||
import { playSound, registerSound, unregisterSound } from '../base/sounds/actions';
|
||||
import { addGif } from '../gifs/actions';
|
||||
import { extractGifURL, getGifDisplayMode, isGifEnabled, isGifMessage } from '../gifs/function.any';
|
||||
import { showMessageNotification } from '../notifications/actions';
|
||||
import { NOTIFICATION_TIMEOUT_TYPE } from '../notifications/constants';
|
||||
import { resetNbUnreadPollsMessages } from '../polls/actions';
|
||||
import { ADD_REACTION_MESSAGE } from '../reactions/actionTypes';
|
||||
import { pushReactions } from '../reactions/actions.any';
|
||||
import { ENDPOINT_REACTION_NAME } from '../reactions/constants';
|
||||
import { getReactionMessageFromBuffer, isReactionsEnabled } from '../reactions/functions.any';
|
||||
import { showToolbox } from '../toolbox/actions';
|
||||
import { getVisitorDisplayName } from '../visitors/functions';
|
||||
|
||||
import {
|
||||
ADD_MESSAGE,
|
||||
CLOSE_CHAT,
|
||||
OPEN_CHAT,
|
||||
SEND_MESSAGE,
|
||||
SEND_REACTION,
|
||||
SET_FOCUSED_TAB
|
||||
} from './actionTypes';
|
||||
import {
|
||||
addMessage,
|
||||
addMessageReaction,
|
||||
clearMessages,
|
||||
closeChat,
|
||||
notifyPrivateRecipientsChanged,
|
||||
setPrivateMessageRecipient
|
||||
} from './actions.any';
|
||||
import { ChatPrivacyDialog } from './components';
|
||||
import {
|
||||
ChatTabs,
|
||||
INCOMING_MSG_SOUND_ID,
|
||||
LOBBY_CHAT_MESSAGE,
|
||||
MESSAGE_TYPE_ERROR,
|
||||
MESSAGE_TYPE_LOCAL,
|
||||
MESSAGE_TYPE_REMOTE,
|
||||
MESSAGE_TYPE_SYSTEM
|
||||
} from './constants';
|
||||
import { getUnreadCount, isSendGroupChatDisabled, isVisitorChatParticipant } from './functions';
|
||||
import { INCOMING_MSG_SOUND_FILE } from './sounds';
|
||||
import './subscriber';
|
||||
|
||||
/**
|
||||
* Timeout for when to show the privacy notice after a private message was received.
|
||||
*
|
||||
* E.g. If this value is 20 secs (20000ms), then we show the privacy notice when sending a non private
|
||||
* message after we have received a private message in the last 20 seconds.
|
||||
*/
|
||||
const PRIVACY_NOTICE_TIMEOUT = 20 * 1000;
|
||||
|
||||
/**
|
||||
* Implements the middleware of the chat feature.
|
||||
*
|
||||
* @param {Store} store - The redux store.
|
||||
* @returns {Function}
|
||||
*/
|
||||
MiddlewareRegistry.register(store => next => action => {
|
||||
const { dispatch, getState } = store;
|
||||
const localParticipant = getLocalParticipant(getState());
|
||||
let isOpen, unreadCount;
|
||||
|
||||
switch (action.type) {
|
||||
case ADD_MESSAGE:
|
||||
unreadCount = getUnreadCount(getState());
|
||||
if (action.isReaction) {
|
||||
action.hasRead = false;
|
||||
} else {
|
||||
unreadCount = action.hasRead ? 0 : unreadCount + 1;
|
||||
}
|
||||
isOpen = getState()['features/chat'].isOpen;
|
||||
|
||||
if (typeof APP !== 'undefined') {
|
||||
APP.API.notifyChatUpdated(unreadCount, isOpen);
|
||||
}
|
||||
break;
|
||||
|
||||
case APP_WILL_MOUNT:
|
||||
dispatch(
|
||||
registerSound(INCOMING_MSG_SOUND_ID, INCOMING_MSG_SOUND_FILE));
|
||||
break;
|
||||
|
||||
case APP_WILL_UNMOUNT:
|
||||
dispatch(unregisterSound(INCOMING_MSG_SOUND_ID));
|
||||
break;
|
||||
|
||||
case CONFERENCE_JOINED:
|
||||
_addChatMsgListener(action.conference, store);
|
||||
break;
|
||||
|
||||
case CLOSE_CHAT: {
|
||||
const { focusedTab } = getState()['features/chat'];
|
||||
|
||||
if (focusedTab === ChatTabs.CHAT) {
|
||||
unreadCount = 0;
|
||||
|
||||
if (typeof APP !== 'undefined') {
|
||||
APP.API.notifyChatUpdated(unreadCount, false);
|
||||
}
|
||||
} else if (focusedTab === ChatTabs.POLLS) {
|
||||
dispatch(resetNbUnreadPollsMessages());
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case ENDPOINT_MESSAGE_RECEIVED: {
|
||||
const state = store.getState();
|
||||
|
||||
if (!isReactionsEnabled(state)) {
|
||||
return next(action);
|
||||
}
|
||||
|
||||
const { participant, data } = action;
|
||||
|
||||
if (data?.name === ENDPOINT_REACTION_NAME) {
|
||||
// Skip duplicates, keep just 3.
|
||||
const reactions = Array.from(new Set(data.reactions)).slice(0, 3) as string[];
|
||||
|
||||
store.dispatch(pushReactions(reactions));
|
||||
|
||||
_handleReceivedMessage(store, {
|
||||
participantId: participant.getId(),
|
||||
message: getReactionMessageFromBuffer(reactions),
|
||||
privateMessage: false,
|
||||
lobbyChat: false,
|
||||
timestamp: data.timestamp
|
||||
}, false, true);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case NON_PARTICIPANT_MESSAGE_RECEIVED: {
|
||||
const { participantId, json: data } = action;
|
||||
|
||||
if (data?.type === MESSAGE_TYPE_SYSTEM && data.message) {
|
||||
_handleReceivedMessage(store, {
|
||||
displayName: data.displayName ?? i18next.t('chat.systemDisplayName'),
|
||||
participantId,
|
||||
lobbyChat: false,
|
||||
message: data.message,
|
||||
privateMessage: true,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case SET_FOCUSED_TAB:
|
||||
case OPEN_CHAT: {
|
||||
const focusedTab = action.tabId || getState()['features/chat'].focusedTab;
|
||||
|
||||
if (focusedTab === ChatTabs.CHAT) {
|
||||
unreadCount = 0;
|
||||
|
||||
if (typeof APP !== 'undefined') {
|
||||
APP.API.notifyChatUpdated(unreadCount, true);
|
||||
}
|
||||
|
||||
const { privateMessageRecipient } = store.getState()['features/chat'];
|
||||
|
||||
if (
|
||||
isSendGroupChatDisabled(store.getState())
|
||||
&& privateMessageRecipient
|
||||
&& !action.participant
|
||||
) {
|
||||
const participant = getParticipantById(store.getState(), privateMessageRecipient.id);
|
||||
|
||||
if (participant) {
|
||||
action.participant = participant;
|
||||
} else if (isVisitorChatParticipant(privateMessageRecipient)) {
|
||||
// Handle visitor participants that don't exist in the main participant list
|
||||
action.participant = privateMessageRecipient;
|
||||
}
|
||||
}
|
||||
} else if (focusedTab === ChatTabs.POLLS) {
|
||||
dispatch(resetNbUnreadPollsMessages());
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case PARTICIPANT_JOINED:
|
||||
case PARTICIPANT_LEFT:
|
||||
case PARTICIPANT_UPDATED: {
|
||||
if (_shouldNotifyPrivateRecipientsChanged(store, action)) {
|
||||
const result = next(action);
|
||||
|
||||
dispatch(notifyPrivateRecipientsChanged());
|
||||
|
||||
return result;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case SEND_MESSAGE: {
|
||||
const state = store.getState();
|
||||
const conference = getCurrentConference(state);
|
||||
|
||||
if (conference) {
|
||||
// There may be cases when we intend to send a private message but we forget to set the
|
||||
// recipient. This logic tries to mitigate this risk.
|
||||
const shouldSendPrivateMessageTo = _shouldSendPrivateMessageTo(state, action);
|
||||
|
||||
if (shouldSendPrivateMessageTo) {
|
||||
const participantExists = getParticipantById(state, shouldSendPrivateMessageTo.id);
|
||||
|
||||
if (participantExists || shouldSendPrivateMessageTo.isFromVisitor) {
|
||||
dispatch(openDialog(ChatPrivacyDialog, {
|
||||
message: action.message,
|
||||
participantID: shouldSendPrivateMessageTo.id,
|
||||
isFromVisitor: shouldSendPrivateMessageTo.isFromVisitor,
|
||||
displayName: shouldSendPrivateMessageTo.name
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
// Sending the message if privacy notice doesn't need to be shown.
|
||||
|
||||
const { privateMessageRecipient, isLobbyChatActive, lobbyMessageRecipient }
|
||||
= state['features/chat'];
|
||||
|
||||
if (typeof APP !== 'undefined') {
|
||||
APP.API.notifySendingChatMessage(action.message, Boolean(privateMessageRecipient));
|
||||
}
|
||||
|
||||
if (isLobbyChatActive && lobbyMessageRecipient) {
|
||||
conference.sendLobbyMessage({
|
||||
type: LOBBY_CHAT_MESSAGE,
|
||||
message: action.message
|
||||
}, lobbyMessageRecipient.id);
|
||||
_persistSentPrivateMessage(store, lobbyMessageRecipient, action.message, true);
|
||||
} else if (privateMessageRecipient) {
|
||||
conference.sendPrivateTextMessage(privateMessageRecipient.id, action.message, 'body', isVisitorChatParticipant(privateMessageRecipient));
|
||||
_persistSentPrivateMessage(store, privateMessageRecipient, action.message);
|
||||
} else {
|
||||
conference.sendTextMessage(action.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case SEND_REACTION: {
|
||||
const state = store.getState();
|
||||
const conference = getCurrentConference(state);
|
||||
|
||||
if (conference) {
|
||||
const { reaction, messageId, receiverId } = action;
|
||||
|
||||
conference.sendReaction(reaction, messageId, receiverId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case ADD_REACTION_MESSAGE: {
|
||||
if (localParticipant?.id) {
|
||||
_handleReceivedMessage(store, {
|
||||
participantId: localParticipant.id,
|
||||
message: action.message,
|
||||
privateMessage: false,
|
||||
timestamp: Date.now(),
|
||||
lobbyChat: false
|
||||
}, false, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return next(action);
|
||||
});
|
||||
|
||||
/**
|
||||
* Set up state change listener to perform maintenance tasks when the conference
|
||||
* is left or failed, e.g. Clear messages or close the chat modal if it's left
|
||||
* open.
|
||||
*/
|
||||
StateListenerRegistry.register(
|
||||
state => getCurrentConference(state),
|
||||
(conference, { dispatch, getState }, previousConference) => {
|
||||
if (conference !== previousConference) {
|
||||
// conference changed, left or failed...
|
||||
|
||||
if (getState()['features/chat'].isOpen) {
|
||||
// Closes the chat if it's left open.
|
||||
dispatch(closeChat());
|
||||
}
|
||||
|
||||
// Clear chat messages.
|
||||
dispatch(clearMessages());
|
||||
}
|
||||
});
|
||||
|
||||
StateListenerRegistry.register(
|
||||
state => state['features/chat'].isOpen,
|
||||
(isOpen, { dispatch }) => {
|
||||
if (typeof APP !== 'undefined' && isOpen) {
|
||||
dispatch(showToolbox());
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Checks whether a notification for private chat recipients is needed.
|
||||
*
|
||||
* @param {IStore} store - The redux store.
|
||||
* @param {{ participant: IParticipant, type: string }} action - The action.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function _shouldNotifyPrivateRecipientsChanged(
|
||||
store: IStore, action: { participant: IParticipant; type: string; }
|
||||
) {
|
||||
const { type, participant } = action;
|
||||
|
||||
if ([ PARTICIPANT_LEFT, PARTICIPANT_JOINED ].includes(type)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const { id, name } = participant;
|
||||
|
||||
return name !== getParticipantDisplayName(store, id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers listener for {@link JitsiConferenceEvents.MESSAGE_RECEIVED} that
|
||||
* will perform various chat related activities.
|
||||
*
|
||||
* @param {JitsiConference} conference - The conference instance on which the
|
||||
* new event listener will be registered.
|
||||
* @param {Object} store - The redux store object.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
function _addChatMsgListener(conference: IJitsiConference, store: IStore) {
|
||||
if (store.getState()['features/base/config'].iAmRecorder) {
|
||||
// We don't register anything on web if we are in iAmRecorder mode
|
||||
return;
|
||||
}
|
||||
|
||||
conference.on(
|
||||
JitsiConferenceEvents.MESSAGE_RECEIVED,
|
||||
/* eslint-disable max-params */
|
||||
(participantId: string, message: string, timestamp: number,
|
||||
displayName: string, isFromVisitor: boolean, messageId: string) => {
|
||||
/* eslint-enable max-params */
|
||||
_onConferenceMessageReceived(store, {
|
||||
// in case of messages coming from visitors we can have unknown id
|
||||
participantId: participantId || displayName,
|
||||
message,
|
||||
timestamp,
|
||||
displayName,
|
||||
isFromVisitor,
|
||||
messageId,
|
||||
privateMessage: false });
|
||||
|
||||
if (isSendGroupChatDisabled(store.getState()) && participantId) {
|
||||
const participant = getParticipantById(store, participantId);
|
||||
|
||||
store.dispatch(setPrivateMessageRecipient(participant));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
conference.on(
|
||||
JitsiConferenceEvents.REACTION_RECEIVED,
|
||||
(participantId: string, reactionList: string[], messageId: string) => {
|
||||
_onReactionReceived(store, {
|
||||
participantId,
|
||||
reactionList,
|
||||
messageId
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
conference.on(
|
||||
JitsiConferenceEvents.PRIVATE_MESSAGE_RECEIVED,
|
||||
(participantId: string, message: string, timestamp: number, messageId: string, displayName?: string, isFromVisitor?: boolean) => {
|
||||
_onConferenceMessageReceived(store, {
|
||||
participantId,
|
||||
message,
|
||||
timestamp,
|
||||
displayName,
|
||||
messageId,
|
||||
privateMessage: true,
|
||||
isFromVisitor
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
conference.on(
|
||||
JitsiConferenceEvents.CONFERENCE_ERROR, (errorType: string, error: Error) => {
|
||||
errorType === JitsiConferenceErrors.CHAT_ERROR && _handleChatError(store, error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a received message.
|
||||
*
|
||||
* @param {Object} store - Redux store.
|
||||
* @param {Object} message - The message object.
|
||||
* @returns {void}
|
||||
*/
|
||||
function _onConferenceMessageReceived(store: IStore,
|
||||
{ displayName, isFromVisitor, message, messageId, participantId, privateMessage, timestamp }: {
|
||||
displayName?: string; isFromVisitor?: boolean; message: string; messageId?: string;
|
||||
participantId: string; privateMessage: boolean; timestamp: number; }
|
||||
) {
|
||||
|
||||
const isGif = isGifEnabled(store.getState()) && isGifMessage(message);
|
||||
|
||||
if (isGif) {
|
||||
_handleGifMessageReceived(store, participantId, message);
|
||||
if (getGifDisplayMode(store.getState()) === 'tile') {
|
||||
return;
|
||||
}
|
||||
}
|
||||
_handleReceivedMessage(store, {
|
||||
displayName,
|
||||
isFromVisitor,
|
||||
participantId,
|
||||
message,
|
||||
privateMessage,
|
||||
lobbyChat: false,
|
||||
timestamp,
|
||||
messageId
|
||||
}, true, isGif);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a received reaction.
|
||||
*
|
||||
* @param {Object} store - Redux store.
|
||||
* @param {string} participantId - Id of the participant that sent the message.
|
||||
* @param {string} reactionList - The list of received reactions.
|
||||
* @param {string} messageId - The id of the message that the reaction is for.
|
||||
* @returns {void}
|
||||
*/
|
||||
function _onReactionReceived(store: IStore, { participantId, reactionList, messageId }: {
|
||||
messageId: string; participantId: string; reactionList: string[]; }) {
|
||||
|
||||
const reactionPayload = {
|
||||
participantId,
|
||||
reactionList,
|
||||
messageId
|
||||
};
|
||||
|
||||
store.dispatch(addMessageReaction(reactionPayload));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a received gif message.
|
||||
*
|
||||
* @param {Object} store - Redux store.
|
||||
* @param {string} participantId - Id of the participant that sent the message.
|
||||
* @param {string} message - The message sent.
|
||||
* @returns {void}
|
||||
*/
|
||||
function _handleGifMessageReceived(store: IStore, participantId: string, message: string) {
|
||||
const url = extractGifURL(message);
|
||||
|
||||
store.dispatch(addGif(participantId, url));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a chat error received from the xmpp server.
|
||||
*
|
||||
* @param {Store} store - The Redux store.
|
||||
* @param {string} error - The error message.
|
||||
* @returns {void}
|
||||
*/
|
||||
function _handleChatError({ dispatch }: IStore, error: Error) {
|
||||
dispatch(addMessage({
|
||||
hasRead: true,
|
||||
messageType: MESSAGE_TYPE_ERROR,
|
||||
message: error,
|
||||
privateMessage: false,
|
||||
timestamp: Date.now()
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Function to handle an incoming chat message from lobby room.
|
||||
*
|
||||
* @param {string} message - The message received.
|
||||
* @param {string} participantId - The participant id.
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function handleLobbyMessageReceived(message: string, participantId: string) {
|
||||
return async (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
|
||||
_handleReceivedMessage({ dispatch,
|
||||
getState }, { participantId,
|
||||
message,
|
||||
privateMessage: false,
|
||||
lobbyChat: true,
|
||||
timestamp: Date.now() });
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Function to get lobby chat user display name.
|
||||
*
|
||||
* @param {Store} state - The Redux store.
|
||||
* @param {string} participantId - The knocking participant id.
|
||||
* @returns {string}
|
||||
*/
|
||||
function getLobbyChatDisplayName(state: IReduxState, participantId: string) {
|
||||
const { knockingParticipants } = state['features/lobby'];
|
||||
const { lobbyMessageRecipient } = state['features/chat'];
|
||||
|
||||
if (participantId === lobbyMessageRecipient?.id) {
|
||||
return lobbyMessageRecipient.name;
|
||||
}
|
||||
|
||||
const knockingParticipant = knockingParticipants.find(p => p.id === participantId);
|
||||
|
||||
if (knockingParticipant) {
|
||||
return knockingParticipant.name;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Function to handle an incoming chat message.
|
||||
*
|
||||
* @param {Store} store - The Redux store.
|
||||
* @param {Object} message - The message object.
|
||||
* @param {boolean} shouldPlaySound - Whether to play the incoming message sound.
|
||||
* @param {boolean} isReaction - Whether the message is a reaction message.
|
||||
* @returns {void}
|
||||
*/
|
||||
function _handleReceivedMessage({ dispatch, getState }: IStore,
|
||||
{ displayName, isFromVisitor, lobbyChat, message, messageId, participantId, privateMessage, timestamp }: {
|
||||
displayName?: string; isFromVisitor?: boolean; lobbyChat: boolean; message: string;
|
||||
messageId?: string; participantId: string; privateMessage: boolean; timestamp: number; },
|
||||
shouldPlaySound = true,
|
||||
isReaction = false
|
||||
) {
|
||||
// Logic for all platforms:
|
||||
const state = getState();
|
||||
const { isOpen: isChatOpen } = state['features/chat'];
|
||||
const { soundsIncomingMessage: soundEnabled, userSelectedNotifications } = state['features/base/settings'];
|
||||
|
||||
if (soundEnabled && shouldPlaySound && !isChatOpen) {
|
||||
dispatch(playSound(INCOMING_MSG_SOUND_ID));
|
||||
}
|
||||
|
||||
// Provide a default for the case when a message is being
|
||||
// backfilled for a participant that has left the conference.
|
||||
const participant = getParticipantById(state, participantId) || { local: undefined };
|
||||
|
||||
const localParticipant = getLocalParticipant(getState);
|
||||
let _displayName, displayNameToShow;
|
||||
|
||||
if (lobbyChat) {
|
||||
displayNameToShow = _displayName = getLobbyChatDisplayName(state, participantId);
|
||||
} else if (isFromVisitor) {
|
||||
_displayName = getVisitorDisplayName(state, displayName);
|
||||
displayNameToShow = `${_displayName} ${i18next.t('visitors.chatIndicator')}`;
|
||||
} else {
|
||||
displayNameToShow = _displayName = getParticipantDisplayName(state, participantId);
|
||||
}
|
||||
|
||||
const hasRead = participant.local || isChatOpen;
|
||||
const timestampToDate = timestamp ? new Date(timestamp) : new Date();
|
||||
const millisecondsTimestamp = timestampToDate.getTime();
|
||||
|
||||
// skip message notifications on join (the messages having timestamp - coming from the history)
|
||||
const shouldShowNotification = userSelectedNotifications?.['notify.chatMessages']
|
||||
&& !hasRead && !isReaction && (!timestamp || lobbyChat);
|
||||
|
||||
dispatch(addMessage({
|
||||
displayName: _displayName,
|
||||
hasRead,
|
||||
participantId,
|
||||
messageType: participant.local ? MESSAGE_TYPE_LOCAL : MESSAGE_TYPE_REMOTE,
|
||||
message,
|
||||
privateMessage,
|
||||
lobbyChat,
|
||||
recipient: getParticipantDisplayName(state, localParticipant?.id ?? ''),
|
||||
timestamp: millisecondsTimestamp,
|
||||
messageId,
|
||||
isReaction,
|
||||
isFromVisitor
|
||||
}));
|
||||
|
||||
if (shouldShowNotification) {
|
||||
dispatch(showMessageNotification({
|
||||
title: displayNameToShow,
|
||||
description: message
|
||||
}, NOTIFICATION_TIMEOUT_TYPE.MEDIUM));
|
||||
}
|
||||
|
||||
if (typeof APP !== 'undefined') {
|
||||
// Logic for web only:
|
||||
|
||||
APP.API.notifyReceivedChatMessage({
|
||||
body: message,
|
||||
from: participantId,
|
||||
nick: displayNameToShow,
|
||||
privateMessage,
|
||||
ts: timestamp
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for recipient objects used in private messaging.
|
||||
*/
|
||||
interface IRecipient {
|
||||
id: string;
|
||||
isVisitor?: boolean;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists the sent private messages as if they were received over the muc.
|
||||
*
|
||||
* This is required as we rely on the fact that we receive all messages from the muc that we send
|
||||
* (as they are sent to everybody), but we don't receive the private messages we send to another participant.
|
||||
* But those messages should be in the store as well, otherwise they don't appear in the chat window.
|
||||
*
|
||||
* @param {Store} store - The Redux store.
|
||||
* @param {IRecipient} recipient - The recipient the private message was sent to.
|
||||
* @param {string} message - The sent message.
|
||||
* @param {boolean} isLobbyPrivateMessage - Is a lobby message.
|
||||
* @returns {void}
|
||||
*/
|
||||
function _persistSentPrivateMessage({ dispatch, getState }: IStore, recipient: IRecipient,
|
||||
message: string, isLobbyPrivateMessage = false) {
|
||||
const state = getState();
|
||||
const localParticipant = getLocalParticipant(state);
|
||||
|
||||
if (!localParticipant?.id) {
|
||||
return;
|
||||
}
|
||||
const displayName = getParticipantDisplayName(state, localParticipant.id);
|
||||
const { lobbyMessageRecipient } = state['features/chat'];
|
||||
|
||||
const recipientName
|
||||
= recipient.isVisitor
|
||||
? getVisitorDisplayName(state, recipient.name)
|
||||
: (isLobbyPrivateMessage
|
||||
? lobbyMessageRecipient?.name
|
||||
: getParticipantDisplayName(getState, recipient?.id));
|
||||
|
||||
dispatch(addMessage({
|
||||
displayName,
|
||||
hasRead: true,
|
||||
participantId: localParticipant.id,
|
||||
messageType: MESSAGE_TYPE_LOCAL,
|
||||
message,
|
||||
privateMessage: !isLobbyPrivateMessage,
|
||||
lobbyChat: isLobbyPrivateMessage,
|
||||
recipient: recipientName,
|
||||
sentToVisitor: recipient.isVisitor,
|
||||
timestamp: Date.now()
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the participant info for who we may have wanted to send the message
|
||||
* that we're about to send.
|
||||
*
|
||||
* @param {Object} state - The Redux state.
|
||||
* @param {Object} action - The action being dispatched now.
|
||||
* @returns {IRecipient?} - The recipient info or undefined if no notice should be shown.
|
||||
*/
|
||||
function _shouldSendPrivateMessageTo(state: IReduxState, action: AnyAction) {
|
||||
if (action.ignorePrivacy) {
|
||||
// Shortcut: this is only true, if we already displayed the notice, so no need to show it again.
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { messages, privateMessageRecipient } = state['features/chat'];
|
||||
|
||||
if (privateMessageRecipient) {
|
||||
// We're already sending a private message, no need to warn about privacy.
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!messages.length) {
|
||||
// No messages yet, no need to warn for privacy.
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Platforms sort messages differently
|
||||
const lastMessage = navigator.product === 'ReactNative'
|
||||
? messages[0] : messages[messages.length - 1];
|
||||
|
||||
if (lastMessage.messageType === MESSAGE_TYPE_LOCAL) {
|
||||
// The sender is probably aware of any private messages as already sent
|
||||
// a message since then. Doesn't make sense to display the notice now.
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (lastMessage.privateMessage) {
|
||||
// We show the notice if the last received message was private.
|
||||
return {
|
||||
id: lastMessage.participantId,
|
||||
isFromVisitor: Boolean(lastMessage.isFromVisitor),
|
||||
name: lastMessage.displayName
|
||||
};
|
||||
}
|
||||
|
||||
// But messages may come rapidly, we want to protect our users from mis-sending a message
|
||||
// even when there was a reasonable recently received private message.
|
||||
const now = Date.now();
|
||||
const recentPrivateMessages = messages.filter(
|
||||
message =>
|
||||
message.messageType !== MESSAGE_TYPE_LOCAL
|
||||
&& message.privateMessage
|
||||
&& message.timestamp + PRIVACY_NOTICE_TIMEOUT > now);
|
||||
const recentPrivateMessage = navigator.product === 'ReactNative'
|
||||
? recentPrivateMessages[0] : recentPrivateMessages[recentPrivateMessages.length - 1];
|
||||
|
||||
if (recentPrivateMessage) {
|
||||
return {
|
||||
id: recentPrivateMessage.participantId,
|
||||
isFromVisitor: Boolean(recentPrivateMessage.isFromVisitor),
|
||||
name: recentPrivateMessage.displayName
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
276
react/features/chat/reducer.ts
Normal file
276
react/features/chat/reducer.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
import { UPDATE_CONFERENCE_METADATA } from '../base/conference/actionTypes';
|
||||
import { ILocalParticipant, IParticipant } from '../base/participants/types';
|
||||
import ReducerRegistry from '../base/redux/ReducerRegistry';
|
||||
import { IVisitorChatParticipant } from '../visitors/types';
|
||||
|
||||
import {
|
||||
ADD_MESSAGE,
|
||||
ADD_MESSAGE_REACTION,
|
||||
CLEAR_MESSAGES,
|
||||
CLOSE_CHAT,
|
||||
EDIT_MESSAGE,
|
||||
NOTIFY_PRIVATE_RECIPIENTS_CHANGED,
|
||||
OPEN_CHAT,
|
||||
REMOVE_LOBBY_CHAT_PARTICIPANT,
|
||||
SET_CHAT_IS_RESIZING,
|
||||
SET_CHAT_WIDTH,
|
||||
SET_FOCUSED_TAB,
|
||||
SET_LOBBY_CHAT_ACTIVE_STATE,
|
||||
SET_LOBBY_CHAT_RECIPIENT,
|
||||
SET_PRIVATE_MESSAGE_RECIPIENT,
|
||||
SET_USER_CHAT_WIDTH
|
||||
} from './actionTypes';
|
||||
import { CHAT_SIZE, ChatTabs } from './constants';
|
||||
import { IMessage } from './types';
|
||||
|
||||
const DEFAULT_STATE = {
|
||||
groupChatWithPermissions: false,
|
||||
isOpen: false,
|
||||
messages: [],
|
||||
notifyPrivateRecipientsChangedTimestamp: undefined,
|
||||
reactions: {},
|
||||
nbUnreadMessages: 0,
|
||||
privateMessageRecipient: undefined,
|
||||
lobbyMessageRecipient: undefined,
|
||||
isLobbyChatActive: false,
|
||||
focusedTab: ChatTabs.CHAT,
|
||||
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;
|
||||
messages: IMessage[];
|
||||
nbUnreadMessages: number;
|
||||
notifyPrivateRecipientsChangedTimestamp?: number;
|
||||
privateMessageRecipient?: IParticipant | IVisitorChatParticipant;
|
||||
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,
|
||||
error: action.error,
|
||||
isFromVisitor: Boolean(action.isFromVisitor),
|
||||
participantId: action.participantId,
|
||||
isReaction: action.isReaction,
|
||||
messageId: action.messageId,
|
||||
messageType: action.messageType,
|
||||
message: action.message,
|
||||
reactions: action.reactions,
|
||||
privateMessage: action.privateMessage,
|
||||
lobbyChat: action.lobbyChat,
|
||||
recipient: action.recipient,
|
||||
sentToVisitor: Boolean(action.sentToVisitor),
|
||||
timestamp: action.timestamp
|
||||
};
|
||||
|
||||
// React native, unlike web, needs a reverse sorted message list.
|
||||
const messages = navigator.product === 'ReactNative'
|
||||
? [
|
||||
newMessage,
|
||||
...state.messages
|
||||
]
|
||||
: [
|
||||
...state.messages,
|
||||
newMessage
|
||||
];
|
||||
|
||||
return {
|
||||
...state,
|
||||
lastReadMessage:
|
||||
action.hasRead ? newMessage : state.lastReadMessage,
|
||||
nbUnreadMessages: state.focusedTab !== ChatTabs.CHAT ? state.nbUnreadMessages + 1 : state.nbUnreadMessages,
|
||||
messages
|
||||
};
|
||||
}
|
||||
|
||||
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_MESSAGES:
|
||||
return {
|
||||
...state,
|
||||
lastReadMessage: undefined,
|
||||
messages: []
|
||||
};
|
||||
|
||||
case EDIT_MESSAGE: {
|
||||
let found = false;
|
||||
const newMessage = action.message;
|
||||
const messages = state.messages.map(m => {
|
||||
if (m.messageId === newMessage.messageId) {
|
||||
found = true;
|
||||
|
||||
return newMessage;
|
||||
}
|
||||
|
||||
return m;
|
||||
});
|
||||
|
||||
// no change
|
||||
if (!found) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
messages
|
||||
};
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
|
||||
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,
|
||||
nbUnreadMessages: action.tabId === ChatTabs.CHAT ? 0 : state.nbUnreadMessages
|
||||
};
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
return state;
|
||||
});
|
||||
22
react/features/chat/smileys.ts
Normal file
22
react/features/chat/smileys.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
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:'
|
||||
};
|
||||
7
react/features/chat/sounds.ts
Normal file
7
react/features/chat/sounds.ts
Normal 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';
|
||||
0
react/features/chat/subscriber.native.ts
Normal file
0
react/features/chat/subscriber.native.ts
Normal file
73
react/features/chat/subscriber.web.ts
Normal file
73
react/features/chat/subscriber.web.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
82
react/features/chat/types.ts
Normal file
82
react/features/chat/types.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { WithTranslation } from 'react-i18next';
|
||||
|
||||
import { IStore } from '../app/types';
|
||||
|
||||
export interface IMessage {
|
||||
displayName: string;
|
||||
error?: Object;
|
||||
isFromVisitor?: boolean;
|
||||
isReaction: boolean;
|
||||
lobbyChat: boolean;
|
||||
message: string;
|
||||
messageId: string;
|
||||
messageType: string;
|
||||
participantId: string;
|
||||
privateMessage: boolean;
|
||||
reactions: Map<string, Set<string>>;
|
||||
recipient: 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.
|
||||
*/
|
||||
_nbUnreadMessages: 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;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Reference in New Issue
Block a user