init
Some checks failed
Close stale issues and PRs / stale (push) Has been cancelled

This commit is contained in:
2025-09-02 14:49:16 +08:00
commit 38ba663466
2885 changed files with 391107 additions and 0 deletions

View File

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

View File

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

View File

@@ -0,0 +1,99 @@
import { PureComponent } from 'react';
import { WithTranslation } from 'react-i18next';
import { IReduxState, IStore } from '../../app/types';
import { getParticipantDisplayName, isLocalParticipantModerator } from '../../base/participants/functions';
import { 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
};
}

View File

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

View File

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

View File

@@ -0,0 +1,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 } />
);
}));

View 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));

View 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));

View 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));

View File

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

View File

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

View File

@@ -0,0 +1,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;

View 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));

View File

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

View File

@@ -0,0 +1,110 @@
import { connect } from 'react-redux';
import { IReduxState } from '../../../app/types';
import { CHAT_ENABLED } from '../../../base/flags/constants';
import { getFeatureFlag } from '../../../base/flags/functions';
import { translate } from '../../../base/i18n/functions';
import { IconMessage, IconReply } from '../../../base/icons/svg';
import { getParticipantById } from '../../../base/participants/functions';
import { IParticipant } from '../../../base/participants/types';
import AbstractButton, { IProps as AbstractButtonProps } from '../../../base/toolbox/components/AbstractButton';
import { arePollsDisabled } from '../../../conference/functions.any';
import { navigate } from '../../../mobile/navigation/components/conference/ConferenceNavigationContainerRef';
import { screen } from '../../../mobile/navigation/routes';
import { handleLobbyChatInitialized, openChat } from '../../actions.native';
export interface IProps extends AbstractButtonProps {
/**
* True if message is a lobby chat message.
*/
_isLobbyMessage: boolean;
/**
* True if the polls feature is disabled.
*/
_isPollsDisabled?: boolean;
/**
* The participant object retrieved from Redux.
*/
_participant?: IParticipant;
/**
* The ID of the participant that the message is to be sent.
*/
participantID: string;
/**
* True if the button is rendered as a reply button.
*/
reply: boolean;
}
/**
* Class to render a button that initiates the sending of a private message through chat.
*/
class PrivateMessageButton extends AbstractButton<IProps, any> {
override accessibilityLabel = 'toolbar.accessibilityLabel.privateMessage';
override icon = IconMessage;
override label = 'toolbar.privateMessage';
override toggledIcon = IconReply;
/**
* Handles clicking / pressing the button.
*
* @private
* @returns {void}
*/
override _handleClick() {
if (this.props._isLobbyMessage) {
this.props.dispatch(handleLobbyChatInitialized(this.props.participantID));
}
this.props.dispatch(openChat(this.props._participant));
this.props._isPollsDisabled
? navigate(screen.conference.chat, {
privateMessageRecipient: this.props._participant
})
: navigate(screen.conference.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));

View 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
}
};

View 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));

View 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));

View File

@@ -0,0 +1,74 @@
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { IReduxState } from '../../../app/types';
import { getUnreadPollCount } from '../../../polls/functions';
import { getUnreadCount } 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);

View 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;

View 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)));

View 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));

View 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;

View File

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

View File

@@ -0,0 +1,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>
);
}

View 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));

View File

@@ -0,0 +1,60 @@
import { Theme } from '@mui/material';
import React, { useCallback } from 'react';
import { makeStyles } from 'tss-react/mui';
interface IProps {
onSelect: (emoji: string) => void;
}
const useStyles = makeStyles()((theme: Theme) => {
return {
emojiGrid: {
display: 'flex',
flexDirection: 'row',
borderRadius: '4px',
backgroundColor: theme.palette.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;

View File

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

View File

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

View File

@@ -0,0 +1,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);
}
}

View 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;

View File

@@ -0,0 +1,99 @@
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { connect } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { IconCloseLarge } from '../../../base/icons/svg';
import Button from '../../../base/ui/components/web/Button';
import { BUTTON_TYPES } from '../../../base/ui/constants.any';
import {
IProps,
_mapDispatchToProps,
_mapStateToProps
} from '../AbstractMessageRecipient';
const useStyles = makeStyles()(theme => {
return {
container: {
margin: '0 16px 8px',
padding: '6px',
paddingLeft: '16px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
backgroundColor: theme.palette.support05,
borderRadius: theme.shape.borderRadius,
...theme.typography.bodyShortRegular,
color: theme.palette.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);

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,96 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { getParticipantDisplayName } from '../../../base/participants/functions';
import { ISubtitle } from '../../../subtitles/types';
/**
* Props for the SubtitleMessage component.
*/
interface IProps extends ISubtitle {
/**
* Whether to show the display name of the participant.
*/
showDisplayName: boolean;
}
/**
* The styles for the SubtitleMessage component.
*/
const useStyles = makeStyles()(theme => {
return {
messageContainer: {
backgroundColor: theme.palette.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>
);
}

View File

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

View File

@@ -0,0 +1,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>
);
}