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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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