snapshot of https://github.com/jitsi/jitsi-meet.git @ fd1749dd28f4138aa80234fc7f311b19aaffa0e7 (2026-09-15)
This commit is contained in:
131
react/features/chat/components/AbstractChatPrivacyDialog.tsx
Normal file
131
react/features/chat/components/AbstractChatPrivacyDialog.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { PureComponent } from 'react';
|
||||
import { WithTranslation } from 'react-i18next';
|
||||
|
||||
import { IReduxState, IStore } from '../../app/types';
|
||||
import { getParticipantById } from '../../base/participants/functions';
|
||||
import { IParticipant } from '../../base/participants/types';
|
||||
import { IVisitorChatParticipant } from '../../visitors/types';
|
||||
import { sendMessage, setPrivateMessageRecipient } from '../actions';
|
||||
|
||||
interface IProps extends WithTranslation {
|
||||
|
||||
/**
|
||||
* Prop to be invoked on sending the message.
|
||||
*/
|
||||
_onSendMessage: Function;
|
||||
|
||||
/**
|
||||
* Prop to be invoked when the user wants to set a private recipient.
|
||||
*/
|
||||
_onSetMessageRecipient: Function;
|
||||
|
||||
/**
|
||||
* The participant retrieved from Redux by the participantID prop.
|
||||
*/
|
||||
_participant?: IParticipant;
|
||||
|
||||
/**
|
||||
* The display name of the visitor (if applicable).
|
||||
*/
|
||||
displayName?: string;
|
||||
|
||||
/**
|
||||
* Whether the message is from a visitor.
|
||||
*/
|
||||
isFromVisitor?: boolean;
|
||||
|
||||
/**
|
||||
* The message that is about to be sent.
|
||||
*/
|
||||
message: Object;
|
||||
|
||||
/**
|
||||
* The ID of the participant that we think the message may be intended to.
|
||||
*/
|
||||
participantID: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract class for the dialog displayed to avoid mis-sending private messages.
|
||||
*/
|
||||
export class AbstractChatPrivacyDialog extends PureComponent<IProps> {
|
||||
/**
|
||||
* Instantiates a new instance.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this._onSendGroupMessage = this._onSendGroupMessage.bind(this);
|
||||
this._onSendPrivateMessage = this._onSendPrivateMessage.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback to be invoked for cancel action (user wants to send a group message).
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_onSendGroupMessage() {
|
||||
this.props._onSendMessage(this.props.message);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback to be invoked for submit action (user wants to send a private message).
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onSendPrivateMessage() {
|
||||
const { message, _onSendMessage, _onSetMessageRecipient, _participant, isFromVisitor, displayName, participantID } = this.props;
|
||||
|
||||
if (isFromVisitor) {
|
||||
// For visitors, create a participant object since they don't exist in the main participant list
|
||||
const visitorParticipant = {
|
||||
id: participantID,
|
||||
name: displayName,
|
||||
isVisitor: true
|
||||
};
|
||||
|
||||
_onSetMessageRecipient(visitorParticipant);
|
||||
} else {
|
||||
_onSetMessageRecipient(_participant);
|
||||
}
|
||||
|
||||
_onSendMessage(message);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps part of the props of this component to Redux actions.
|
||||
*
|
||||
* @param {Function} dispatch - The Redux dispatch function.
|
||||
* @returns {IProps}
|
||||
*/
|
||||
export function _mapDispatchToProps(dispatch: IStore['dispatch']) {
|
||||
return {
|
||||
_onSendMessage: (message: string) => {
|
||||
dispatch(sendMessage(message, true));
|
||||
},
|
||||
|
||||
_onSetMessageRecipient: (participant: IParticipant | IVisitorChatParticipant) => {
|
||||
dispatch(setPrivateMessageRecipient(participant));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps part of the Redux store to the props of this component.
|
||||
*
|
||||
* @param {IReduxState} state - The Redux state.
|
||||
* @param {IProps} ownProps - The own props of the component.
|
||||
* @returns {IProps}
|
||||
*/
|
||||
export function _mapStateToProps(state: IReduxState, ownProps: IProps) {
|
||||
return {
|
||||
_participant: ownProps.isFromVisitor ? undefined : getParticipantById(state, ownProps.participantID)
|
||||
};
|
||||
}
|
||||
116
react/features/chat/components/AbstractClosedCaptions.tsx
Normal file
116
react/features/chat/components/AbstractClosedCaptions.tsx
Normal 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;
|
||||
|
||||
65
react/features/chat/components/AbstractMessageContainer.ts
Normal file
65
react/features/chat/components/AbstractMessageContainer.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Component } from 'react';
|
||||
import { ReactReduxContext } from 'react-redux';
|
||||
|
||||
import { IMessage } from '../types';
|
||||
|
||||
export interface IProps {
|
||||
|
||||
/**
|
||||
* The messages array to render.
|
||||
*/
|
||||
messages: IMessage[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract component to display a list of chat messages, grouped by sender.
|
||||
*
|
||||
* @augments PureComponent
|
||||
*/
|
||||
export default class AbstractMessageContainer<P extends IProps, S> extends Component<P, S> {
|
||||
static override contextType = ReactReduxContext;
|
||||
declare context: React.ContextType<typeof ReactReduxContext>;
|
||||
|
||||
static defaultProps = {
|
||||
messages: [] as IMessage[]
|
||||
};
|
||||
|
||||
/**
|
||||
* Iterates over all the messages and creates nested arrays which hold
|
||||
* consecutive messages sent by the same participant.
|
||||
*
|
||||
* @private
|
||||
* @returns {Array<Array<Object>>}
|
||||
*/
|
||||
_getMessagesGroupedBySender() {
|
||||
const messagesCount = this.props.messages.length;
|
||||
const groups: IMessage[][] = [];
|
||||
let currentGrouping: IMessage[] = [];
|
||||
let currentGroupParticipantId;
|
||||
|
||||
const { store } = this.context;
|
||||
const state = store.getState();
|
||||
const { disableReactionsInChat } = state['features/base/config'];
|
||||
|
||||
for (let i = 0; i < messagesCount; i++) {
|
||||
const message = this.props.messages[i];
|
||||
|
||||
if (message.isReaction && disableReactionsInChat) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.participantId === currentGroupParticipantId) {
|
||||
currentGrouping.push(message);
|
||||
} else {
|
||||
currentGrouping.length && groups.push(currentGrouping);
|
||||
|
||||
currentGrouping = [ message ];
|
||||
currentGroupParticipantId = message.participantId;
|
||||
}
|
||||
}
|
||||
|
||||
currentGrouping.length && groups.push(currentGrouping);
|
||||
|
||||
return groups;
|
||||
}
|
||||
}
|
||||
99
react/features/chat/components/AbstractMessageRecipient.ts
Normal file
99
react/features/chat/components/AbstractMessageRecipient.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { PureComponent } from 'react';
|
||||
import { WithTranslation } from 'react-i18next';
|
||||
|
||||
import { IReduxState, IStore } from '../../app/types';
|
||||
import { getParticipantDisplayName, isLocalParticipantModerator } from '../../base/participants/functions';
|
||||
import { 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
|
||||
};
|
||||
}
|
||||
2
react/features/chat/components/index.native.ts
Normal file
2
react/features/chat/components/index.native.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// @ts-ignore
|
||||
export { default as ChatPrivacyDialog } from './native/ChatPrivacyDialog';
|
||||
1
react/features/chat/components/index.web.ts
Normal file
1
react/features/chat/components/index.web.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as ChatPrivacyDialog } from './web/ChatPrivacyDialog';
|
||||
158
react/features/chat/components/native/Chat.tsx
Normal file
158
react/features/chat/components/native/Chat.tsx
Normal 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 } />
|
||||
);
|
||||
}));
|
||||
78
react/features/chat/components/native/ChatButton.ts
Normal file
78
react/features/chat/components/native/ChatButton.ts
Normal 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));
|
||||
201
react/features/chat/components/native/ChatInputBar.tsx
Normal file
201
react/features/chat/components/native/ChatInputBar.tsx
Normal 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));
|
||||
293
react/features/chat/components/native/ChatMessage.tsx
Normal file
293
react/features/chat/components/native/ChatMessage.tsx
Normal 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));
|
||||
81
react/features/chat/components/native/ChatMessageGroup.tsx
Normal file
81
react/features/chat/components/native/ChatMessageGroup.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import React, { Component } from 'react';
|
||||
import { FlatList } from 'react-native';
|
||||
|
||||
import { MESSAGE_TYPE_LOCAL, MESSAGE_TYPE_REMOTE } from '../../constants';
|
||||
import { IMessage } from '../../types';
|
||||
|
||||
import ChatMessage from './ChatMessage';
|
||||
|
||||
interface IProps {
|
||||
|
||||
/**
|
||||
* The messages array to render.
|
||||
*/
|
||||
messages: Array<IMessage>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements a container to render all the chat messages in a conference.
|
||||
*/
|
||||
export default class ChatMessageGroup extends Component<IProps> {
|
||||
/**
|
||||
* Instantiates a new instance of the component.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this._keyExtractor = this._keyExtractor.bind(this);
|
||||
this._renderMessage = this._renderMessage.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements {@code Component#render}.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
override render() {
|
||||
return (
|
||||
<FlatList
|
||||
data = { this.props.messages }
|
||||
inverted = { true }
|
||||
keyExtractor = { this._keyExtractor }
|
||||
renderItem = { this._renderMessage } />
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Key extractor for the flatlist.
|
||||
*
|
||||
* @param {Object} _item - The flatlist item that we need the key to be
|
||||
* generated for.
|
||||
* @param {number} index - The index of the element.
|
||||
* @returns {string}
|
||||
*/
|
||||
_keyExtractor(_item: Object, index: number) {
|
||||
return `key_${index}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single chat message.
|
||||
*
|
||||
* @param {Object} message - The chat message to render.
|
||||
* @returns {React$Element<*>}
|
||||
*/
|
||||
_renderMessage({ index, item: message }: { index: number; item: IMessage; }) {
|
||||
return (
|
||||
<ChatMessage
|
||||
message = { message }
|
||||
showAvatar = {
|
||||
this.props.messages[0].messageType !== MESSAGE_TYPE_LOCAL
|
||||
&& index === this.props.messages.length - 1
|
||||
}
|
||||
showDisplayName = {
|
||||
this.props.messages[0].messageType === MESSAGE_TYPE_REMOTE
|
||||
&& index === this.props.messages.length - 1
|
||||
}
|
||||
showTimestamp = { index === 0 } />
|
||||
);
|
||||
}
|
||||
}
|
||||
30
react/features/chat/components/native/ChatPrivacyDialog.tsx
Normal file
30
react/features/chat/components/native/ChatPrivacyDialog.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import ConfirmDialog from '../../../base/dialog/components/native/ConfirmDialog';
|
||||
import { translate } from '../../../base/i18n/functions';
|
||||
import { AbstractChatPrivacyDialog, _mapDispatchToProps, _mapStateToProps } from '../AbstractChatPrivacyDialog';
|
||||
|
||||
/**
|
||||
* Implements a component for the dialog displayed to avoid mis-sending private messages.
|
||||
*/
|
||||
class ChatPrivacyDialog extends AbstractChatPrivacyDialog {
|
||||
/**
|
||||
* Implements React's {@link Component#render()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
override render() {
|
||||
return (
|
||||
<ConfirmDialog
|
||||
cancelLabel = 'dialog.sendPrivateMessageCancel'
|
||||
confirmLabel = 'dialog.sendPrivateMessageOk'
|
||||
descriptionKey = 'dialog.sendPrivateMessage'
|
||||
onCancel = { this._onSendGroupMessage }
|
||||
onSubmit = { this._onSendPrivateMessage } />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default translate(connect(_mapStateToProps, _mapDispatchToProps)(ChatPrivacyDialog));
|
||||
107
react/features/chat/components/native/ChatSearchBar.tsx
Normal file
107
react/features/chat/components/native/ChatSearchBar.tsx
Normal 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);
|
||||
125
react/features/chat/components/native/ClosedCaptions.tsx
Normal file
125
react/features/chat/components/native/ClosedCaptions.tsx
Normal 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);
|
||||
35
react/features/chat/components/native/GifMessage.tsx
Normal file
35
react/features/chat/components/native/GifMessage.tsx
Normal 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;
|
||||
227
react/features/chat/components/native/MessageContainer.tsx
Normal file
227
react/features/chat/components/native/MessageContainer.tsx
Normal 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));
|
||||
163
react/features/chat/components/native/MessageRecipient.tsx
Normal file
163
react/features/chat/components/native/MessageRecipient.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import React from 'react';
|
||||
import { Text, TouchableHighlight, View, ViewStyle } from 'react-native';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { IReduxState, IStore } from '../../../app/types';
|
||||
import { translate } from '../../../base/i18n/functions';
|
||||
import Icon from '../../../base/icons/components/Icon';
|
||||
import { IconCloseLarge } from '../../../base/icons/svg';
|
||||
import { ILocalParticipant } from '../../../base/participants/types';
|
||||
import {
|
||||
setParams
|
||||
} from '../../../mobile/navigation/components/conference/ConferenceNavigationContainerRef';
|
||||
import { setLobbyChatActiveState, setPrivateMessageRecipient } from '../../actions.any';
|
||||
import AbstractMessageRecipient, {
|
||||
IProps as AbstractProps,
|
||||
_mapStateToProps as _mapStateToPropsAbstract
|
||||
} from '../AbstractMessageRecipient';
|
||||
|
||||
import styles from './styles';
|
||||
|
||||
interface IProps extends AbstractProps {
|
||||
|
||||
/**
|
||||
* The Redux dispatch function.
|
||||
*/
|
||||
dispatch: IStore['dispatch'];
|
||||
|
||||
/**
|
||||
* Is lobby messaging active.
|
||||
*/
|
||||
isLobbyChatActive: boolean;
|
||||
|
||||
/**
|
||||
* The participant string for lobby chat messaging.
|
||||
*/
|
||||
lobbyMessageRecipient?: {
|
||||
id: string;
|
||||
name: string;
|
||||
} | ILocalParticipant;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class to implement the displaying of the recipient of the next message.
|
||||
*/
|
||||
class MessageRecipient extends AbstractMessageRecipient<IProps> {
|
||||
|
||||
/**
|
||||
* Constructor of the component.
|
||||
*
|
||||
* @param {IProps} props - The props of the component.
|
||||
*/
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this._onResetPrivateMessageRecipient = this._onResetPrivateMessageRecipient.bind(this);
|
||||
this._onResetLobbyMessageRecipient = this._onResetLobbyMessageRecipient.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets lobby message recipient from state.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onResetLobbyMessageRecipient() {
|
||||
const { dispatch } = this.props;
|
||||
|
||||
dispatch(setLobbyChatActiveState(false));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets private message recipient from state.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onResetPrivateMessageRecipient() {
|
||||
const { dispatch } = this.props;
|
||||
|
||||
dispatch(setPrivateMessageRecipient());
|
||||
|
||||
setParams({
|
||||
privateMessageRecipient: undefined
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements {@code PureComponent#render}.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
override render() {
|
||||
const {
|
||||
isLobbyChatActive,
|
||||
lobbyMessageRecipient,
|
||||
_privateMessageRecipient,
|
||||
_isVisitor,
|
||||
t
|
||||
} = this.props;
|
||||
|
||||
if (isLobbyChatActive) {
|
||||
return (
|
||||
<View
|
||||
id = 'chat-recipient'
|
||||
style = { styles.lobbyMessageRecipientContainer as ViewStyle }>
|
||||
<Text style = { styles.messageRecipientText }>
|
||||
{ t('chat.lobbyChatMessageTo', {
|
||||
recipient: lobbyMessageRecipient?.name
|
||||
}) }
|
||||
</Text>
|
||||
<TouchableHighlight
|
||||
onPress = { this._onResetLobbyMessageRecipient }>
|
||||
<Icon
|
||||
src = { IconCloseLarge }
|
||||
style = { styles.messageRecipientCancelIcon } />
|
||||
</TouchableHighlight>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!_privateMessageRecipient) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
id = 'message-recipient'
|
||||
style = { styles.messageRecipientContainer as ViewStyle }>
|
||||
<Text style = { styles.messageRecipientText }>
|
||||
{ t('chat.messageTo', {
|
||||
recipient: `${_privateMessageRecipient}${_isVisitor ? ` ${t('visitors.chatIndicator')}` : ''}`
|
||||
}) }
|
||||
</Text>
|
||||
<TouchableHighlight
|
||||
id = 'message-recipient-cancel-button'
|
||||
onPress = { this._onResetPrivateMessageRecipient }
|
||||
underlayColor = { 'transparent' }>
|
||||
<Icon
|
||||
src = { IconCloseLarge }
|
||||
style = { styles.messageRecipientCancelIcon } />
|
||||
</TouchableHighlight>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps part of the redux state to the props of this component.
|
||||
*
|
||||
* @param {Object} state - The Redux state.
|
||||
* @param {any} _ownProps - Component's own props.
|
||||
* @returns {IProps}
|
||||
*/
|
||||
function _mapStateToProps(state: IReduxState, _ownProps: any) {
|
||||
const { lobbyMessageRecipient, isLobbyChatActive } = state['features/chat'];
|
||||
|
||||
return {
|
||||
..._mapStateToPropsAbstract(state, _ownProps),
|
||||
isLobbyChatActive,
|
||||
lobbyMessageRecipient
|
||||
};
|
||||
}
|
||||
|
||||
export default translate(connect(_mapStateToProps)(MessageRecipient));
|
||||
110
react/features/chat/components/native/PrivateMessageButton.tsx
Normal file
110
react/features/chat/components/native/PrivateMessageButton.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { IReduxState } from '../../../app/types';
|
||||
import { CHAT_ENABLED } from '../../../base/flags/constants';
|
||||
import { getFeatureFlag } from '../../../base/flags/functions';
|
||||
import { translate } from '../../../base/i18n/functions';
|
||||
import { IconMessage, IconReply } from '../../../base/icons/svg';
|
||||
import { getParticipantById } from '../../../base/participants/functions';
|
||||
import { IParticipant } from '../../../base/participants/types';
|
||||
import AbstractButton, { IProps as AbstractButtonProps } from '../../../base/toolbox/components/AbstractButton';
|
||||
import { arePollsDisabled } from '../../../conference/functions.any';
|
||||
import { navigate } from '../../../mobile/navigation/components/conference/ConferenceNavigationContainerRef';
|
||||
import { screen } from '../../../mobile/navigation/routes';
|
||||
import { handleLobbyChatInitialized, openChat } from '../../actions.native';
|
||||
|
||||
export interface IProps extends AbstractButtonProps {
|
||||
|
||||
/**
|
||||
* True if message is a lobby chat message.
|
||||
*/
|
||||
_isLobbyMessage: boolean;
|
||||
|
||||
/**
|
||||
* True if the polls feature is disabled.
|
||||
*/
|
||||
_isPollsDisabled?: boolean;
|
||||
|
||||
/**
|
||||
* The participant object retrieved from Redux.
|
||||
*/
|
||||
_participant?: IParticipant;
|
||||
|
||||
/**
|
||||
* The ID of the participant that the message is to be sent.
|
||||
*/
|
||||
participantID: string;
|
||||
|
||||
/**
|
||||
* True if the button is rendered as a reply button.
|
||||
*/
|
||||
reply: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class to render a button that initiates the sending of a private message through chat.
|
||||
*/
|
||||
class PrivateMessageButton extends AbstractButton<IProps, any> {
|
||||
override accessibilityLabel = 'toolbar.accessibilityLabel.privateMessage';
|
||||
override icon = IconMessage;
|
||||
override label = 'toolbar.privateMessage';
|
||||
override toggledIcon = IconReply;
|
||||
|
||||
/**
|
||||
* Handles clicking / pressing the button.
|
||||
*
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
override _handleClick() {
|
||||
if (this.props._isLobbyMessage) {
|
||||
this.props.dispatch(handleLobbyChatInitialized(this.props.participantID));
|
||||
}
|
||||
|
||||
this.props.dispatch(openChat(this.props._participant));
|
||||
|
||||
this.props._isPollsDisabled
|
||||
? navigate(screen.conference.chat, {
|
||||
privateMessageRecipient: this.props._participant
|
||||
})
|
||||
: navigate(screen.conference.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));
|
||||
45
react/features/chat/components/native/SubtitleMessage.tsx
Normal file
45
react/features/chat/components/native/SubtitleMessage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
react/features/chat/components/native/SubtitlesGroup.tsx
Normal file
40
react/features/chat/components/native/SubtitlesGroup.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
462
react/features/chat/components/native/styles.ts
Normal file
462
react/features/chat/components/native/styles.ts
Normal 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'
|
||||
}
|
||||
};
|
||||
720
react/features/chat/components/web/Chat.tsx
Normal file
720
react/features/chat/components/web/Chat.tsx
Normal 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));
|
||||
100
react/features/chat/components/web/ChatButton.tsx
Normal file
100
react/features/chat/components/web/ChatButton.tsx
Normal 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));
|
||||
74
react/features/chat/components/web/ChatCounter.tsx
Normal file
74
react/features/chat/components/web/ChatCounter.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { IReduxState } from '../../../app/types';
|
||||
import { getUnreadPollCount } from '../../../polls/functions';
|
||||
import { getUnreadCount, 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);
|
||||
322
react/features/chat/components/web/ChatHeader.tsx
Normal file
322
react/features/chat/components/web/ChatHeader.tsx
Normal 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;
|
||||
383
react/features/chat/components/web/ChatInput.tsx
Normal file
383
react/features/chat/components/web/ChatInput.tsx
Normal 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)));
|
||||
628
react/features/chat/components/web/ChatMessage.tsx
Normal file
628
react/features/chat/components/web/ChatMessage.tsx
Normal 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));
|
||||
97
react/features/chat/components/web/ChatMessageGroup.tsx
Normal file
97
react/features/chat/components/web/ChatMessageGroup.tsx
Normal 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;
|
||||
34
react/features/chat/components/web/ChatPrivacyDialog.tsx
Normal file
34
react/features/chat/components/web/ChatPrivacyDialog.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { translate } from '../../../base/i18n/functions';
|
||||
import Dialog from '../../../base/ui/components/web/Dialog';
|
||||
import { AbstractChatPrivacyDialog, _mapDispatchToProps, _mapStateToProps } from '../AbstractChatPrivacyDialog';
|
||||
|
||||
/**
|
||||
* Implements a component for the dialog displayed to avoid mis-sending private messages.
|
||||
*/
|
||||
class ChatPrivacyDialog extends AbstractChatPrivacyDialog {
|
||||
/**
|
||||
* Implements React's {@link Component#render()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
override render() {
|
||||
return (
|
||||
<Dialog
|
||||
cancel = {{ translationKey: 'dialog.sendPrivateMessageCancel' }}
|
||||
ok = {{ translationKey: 'dialog.sendPrivateMessageOk' }}
|
||||
onCancel = { this._onSendGroupMessage }
|
||||
onSubmit = { this._onSendPrivateMessage }
|
||||
titleKey = 'dialog.sendPrivateMessageTitle'>
|
||||
<div>
|
||||
{ this.props.t('dialog.sendPrivateMessage') }
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default translate(connect(_mapStateToProps, _mapDispatchToProps)(ChatPrivacyDialog));
|
||||
132
react/features/chat/components/web/ClosedCaptionsTab.tsx
Normal file
132
react/features/chat/components/web/ClosedCaptionsTab.tsx
Normal 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);
|
||||
180
react/features/chat/components/web/DisplayNameForm.tsx
Normal file
180
react/features/chat/components/web/DisplayNameForm.tsx
Normal 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));
|
||||
60
react/features/chat/components/web/EmojiSelector.tsx
Normal file
60
react/features/chat/components/web/EmojiSelector.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Theme } from '@mui/material';
|
||||
import React, { useCallback } from 'react';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
interface IProps {
|
||||
onSelect: (emoji: string) => void;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles()((theme: Theme) => {
|
||||
return {
|
||||
emojiGrid: {
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
borderRadius: '4px',
|
||||
backgroundColor: theme.palette.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;
|
||||
143
react/features/chat/components/web/FileMessage.tsx
Normal file
143
react/features/chat/components/web/FileMessage.tsx
Normal 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;
|
||||
40
react/features/chat/components/web/GifMessage.tsx
Normal file
40
react/features/chat/components/web/GifMessage.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
interface IProps {
|
||||
|
||||
/**
|
||||
* URL of the GIF.
|
||||
*/
|
||||
url: string;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles()(() => {
|
||||
return {
|
||||
container: {
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
maxHeight: '150px',
|
||||
|
||||
'& img': {
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
objectFit: 'contain',
|
||||
flexGrow: 1
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const GifMessage = ({ url }: IProps) => {
|
||||
const { classes: styles } = useStyles();
|
||||
|
||||
return (<div className = { styles.container }>
|
||||
<img
|
||||
alt = { url }
|
||||
src = { url } />
|
||||
</div>);
|
||||
};
|
||||
|
||||
export default GifMessage;
|
||||
54
react/features/chat/components/web/KeyboardAvoider.tsx
Normal file
54
react/features/chat/components/web/KeyboardAvoider.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { isIosMobileBrowser } from '../../../base/environment/utils';
|
||||
|
||||
/**
|
||||
* Component that renders an element to lift the chat input above the Safari keyboard,
|
||||
* computing the appropriate height comparisons based on the {@code visualViewport}.
|
||||
*
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
function KeyboardAvoider() {
|
||||
if (!isIosMobileBrowser()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [ elementHeight, setElementHeight ] = useState(0);
|
||||
const [ storedHeight, setStoredHeight ] = useState(window.innerHeight);
|
||||
|
||||
/**
|
||||
* Handles the resizing of the visual viewport in order to compute
|
||||
* the {@code KeyboardAvoider}'s height.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
function handleViewportResize() {
|
||||
const { innerWidth, visualViewport } = window;
|
||||
const { width, height } = visualViewport ?? {};
|
||||
|
||||
// Compare the widths to make sure the {@code visualViewport} didn't resize due to zooming.
|
||||
if (width === innerWidth) {
|
||||
if (Number(height) < storedHeight) {
|
||||
setElementHeight(storedHeight - Number(height));
|
||||
} else {
|
||||
setElementHeight(0);
|
||||
}
|
||||
setStoredHeight(Number(height));
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// Call the handler in case the keyboard is open when the {@code KeyboardAvoider} is mounted.
|
||||
handleViewportResize();
|
||||
|
||||
window.visualViewport?.addEventListener('resize', handleViewportResize);
|
||||
|
||||
return () => {
|
||||
window.visualViewport?.removeEventListener('resize', handleViewportResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <div style = {{ height: `${elementHeight}px` }} />;
|
||||
}
|
||||
|
||||
export default KeyboardAvoider;
|
||||
378
react/features/chat/components/web/MessageContainer.tsx
Normal file
378
react/features/chat/components/web/MessageContainer.tsx
Normal 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);
|
||||
}
|
||||
}
|
||||
246
react/features/chat/components/web/MessageMenu.tsx
Normal file
246
react/features/chat/components/web/MessageMenu.tsx
Normal 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;
|
||||
99
react/features/chat/components/web/MessageRecipient.tsx
Normal file
99
react/features/chat/components/web/MessageRecipient.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { connect } from 'react-redux';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import { IconCloseLarge } from '../../../base/icons/svg';
|
||||
import Button from '../../../base/ui/components/web/Button';
|
||||
import { BUTTON_TYPES } from '../../../base/ui/constants.any';
|
||||
import {
|
||||
IProps,
|
||||
_mapDispatchToProps,
|
||||
_mapStateToProps
|
||||
} from '../AbstractMessageRecipient';
|
||||
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
container: {
|
||||
margin: '0 16px 8px',
|
||||
padding: '6px',
|
||||
paddingLeft: '16px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
backgroundColor: theme.palette.support05,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
...theme.typography.bodyShortRegular,
|
||||
color: theme.palette.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);
|
||||
89
react/features/chat/components/web/NewMessagesButton.tsx
Normal file
89
react/features/chat/components/web/NewMessagesButton.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import React from 'react';
|
||||
import { WithTranslation } from 'react-i18next';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import { translate } from '../../../base/i18n/functions';
|
||||
import Icon from '../../../base/icons/components/Icon';
|
||||
import { IconArrowDown } from '../../../base/icons/svg';
|
||||
import BaseTheme from '../../../base/ui/components/BaseTheme.web';
|
||||
|
||||
export interface INewMessagesButtonProps extends WithTranslation {
|
||||
|
||||
/**
|
||||
* Function to notify messageContainer when click on goToFirstUnreadMessage button.
|
||||
*/
|
||||
onGoToFirstUnreadMessage: () => void;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
container: {
|
||||
position: 'absolute',
|
||||
left: 'calc(50% - 72px)',
|
||||
bottom: '15px'
|
||||
},
|
||||
|
||||
newMessagesButton: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
height: '32px',
|
||||
padding: '8px',
|
||||
border: 'none',
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
backgroundColor: theme.palette.action02,
|
||||
boxShadow: '0px 3px 16px rgba(0, 0, 0, 0.6), 0px 0px 4px 1px rgba(0, 0, 0, 0.25)',
|
||||
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.action02Hover
|
||||
},
|
||||
|
||||
'&:active': {
|
||||
backgroundColor: theme.palette.action02Active
|
||||
}
|
||||
},
|
||||
|
||||
arrowDownIconContainer: {
|
||||
height: '20px',
|
||||
width: '20px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
|
||||
textContainer: {
|
||||
...theme.typography.bodyShortRegular,
|
||||
color: theme.palette.text04,
|
||||
paddingLeft: '8px'
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
/** NewMessagesButton.
|
||||
*
|
||||
* @param {Function} onGoToFirstUnreadMessage - Function for lifting up onClick event.
|
||||
* @returns {JSX.Element}
|
||||
*/
|
||||
function NewMessagesButton({ onGoToFirstUnreadMessage, t }: INewMessagesButtonProps): JSX.Element {
|
||||
const { classes: styles } = useStyles();
|
||||
|
||||
return (
|
||||
<div
|
||||
className = { styles.container }>
|
||||
<button
|
||||
aria-label = { t('chat.newMessages') }
|
||||
className = { styles.newMessagesButton }
|
||||
onClick = { onGoToFirstUnreadMessage }
|
||||
type = 'button'>
|
||||
<Icon
|
||||
className = { styles.arrowDownIconContainer }
|
||||
color = { BaseTheme.palette.icon04 }
|
||||
size = { 14 }
|
||||
src = { IconArrowDown } />
|
||||
<div className = { styles.textContainer }> { t('chat.newMessages') }</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default translate(NewMessagesButton);
|
||||
74
react/features/chat/components/web/PrivateMessageButton.tsx
Normal file
74
react/features/chat/components/web/PrivateMessageButton.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import { IReduxState } from '../../../app/types';
|
||||
import { CHAT_ENABLED } from '../../../base/flags/constants';
|
||||
import { getFeatureFlag } from '../../../base/flags/functions';
|
||||
import { IconReply } from '../../../base/icons/svg';
|
||||
import { getParticipantById } from '../../../base/participants/functions';
|
||||
import Button from '../../../base/ui/components/web/Button';
|
||||
import { BUTTON_TYPES } from '../../../base/ui/constants.any';
|
||||
import { handleLobbyChatInitialized, openChat } from '../../actions.web';
|
||||
|
||||
export interface IProps {
|
||||
|
||||
/**
|
||||
* True if the message is a lobby chat message.
|
||||
*/
|
||||
isLobbyMessage: boolean;
|
||||
|
||||
/**
|
||||
* The ID of the participant that the message is to be sent.
|
||||
*/
|
||||
participantID: string;
|
||||
|
||||
/**
|
||||
* Whether the button should be visible or not.
|
||||
*/
|
||||
visible?: boolean;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
replyButton: {
|
||||
padding: '2px',
|
||||
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.action03
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const PrivateMessageButton = ({ participantID, isLobbyMessage, visible }: IProps) => {
|
||||
const { classes } = useStyles();
|
||||
const dispatch = useDispatch();
|
||||
const participant = useSelector((state: IReduxState) => getParticipantById(state, participantID));
|
||||
const isVisible = useSelector((state: IReduxState) => getFeatureFlag(state, CHAT_ENABLED, true)) ?? visible;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (isLobbyMessage) {
|
||||
dispatch(handleLobbyChatInitialized(participantID));
|
||||
} else {
|
||||
dispatch(openChat(participant));
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
accessibilityLabel = { t('toolbar.accessibilityLabel.privateMessage') }
|
||||
className = { classes.replyButton }
|
||||
icon = { IconReply }
|
||||
onClick = { handleClick }
|
||||
type = { BUTTON_TYPES.TERTIARY } />
|
||||
);
|
||||
};
|
||||
|
||||
export default PrivateMessageButton;
|
||||
87
react/features/chat/components/web/ReactButton.tsx
Normal file
87
react/features/chat/components/web/ReactButton.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Theme } from '@mui/material';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import { IconFaceSmile } from '../../../base/icons/svg';
|
||||
import Popover from '../../../base/popover/components/Popover.web';
|
||||
import Button from '../../../base/ui/components/web/Button';
|
||||
import { BUTTON_TYPES } from '../../../base/ui/constants.any';
|
||||
import { sendReaction } from '../../actions.any';
|
||||
|
||||
import EmojiSelector from './EmojiSelector';
|
||||
|
||||
interface IProps {
|
||||
messageId: string;
|
||||
receiverId: string;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles()((theme: Theme) => {
|
||||
return {
|
||||
reactButton: {
|
||||
padding: '2px'
|
||||
},
|
||||
reactionPanelContainer: {
|
||||
position: 'relative',
|
||||
display: 'inline-block'
|
||||
},
|
||||
popoverContent: {
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
boxShadow: theme.shadows[3],
|
||||
overflow: 'hidden'
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const ReactButton = ({ messageId, receiverId }: IProps) => {
|
||||
const { classes } = useStyles();
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const onSendReaction = useCallback(emoji => {
|
||||
dispatch(sendReaction(emoji, messageId, receiverId));
|
||||
}, [ dispatch, messageId, receiverId ]);
|
||||
|
||||
const [ isPopoverOpen, setIsPopoverOpen ] = useState(false);
|
||||
|
||||
const handleReactClick = useCallback(() => {
|
||||
setIsPopoverOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setIsPopoverOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleEmojiSelect = useCallback((emoji: string) => {
|
||||
onSendReaction(emoji);
|
||||
handleClose();
|
||||
}, [ onSendReaction, handleClose ]);
|
||||
|
||||
const popoverContent = (
|
||||
<div className = { classes.popoverContent }>
|
||||
<EmojiSelector onSelect = { handleEmojiSelect } />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
content = { popoverContent }
|
||||
onPopoverClose = { handleClose }
|
||||
position = 'top'
|
||||
trigger = 'click'
|
||||
visible = { isPopoverOpen }>
|
||||
<div className = { classes.reactionPanelContainer }>
|
||||
<Button
|
||||
accessibilityLabel = { t('toolbar.accessibilityLabel.react') }
|
||||
className = { classes.reactButton }
|
||||
icon = { IconFaceSmile }
|
||||
onClick = { handleReactClick }
|
||||
type = { BUTTON_TYPES.TERTIARY } />
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReactButton;
|
||||
120
react/features/chat/components/web/SmileysPanel.tsx
Normal file
120
react/features/chat/components/web/SmileysPanel.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import Emoji from 'react-emoji-render';
|
||||
|
||||
import Tooltip from '../../../base/tooltip/components/Tooltip';
|
||||
import { smileys } from '../../smileys';
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} props of {@link SmileysPanel}.
|
||||
*/
|
||||
interface IProps {
|
||||
|
||||
/**
|
||||
* Callback to invoke when a smiley is selected. The smiley will be passed
|
||||
* back.
|
||||
*/
|
||||
onSmileySelect: Function;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements a React Component showing smileys that can be be shown in chat.
|
||||
*
|
||||
* @augments Component
|
||||
*/
|
||||
class SmileysPanel extends PureComponent<IProps> {
|
||||
/**
|
||||
* Initializes a new {@code SmileysPanel} instance.
|
||||
*
|
||||
* @param {*} props - The read-only properties with which the new instance
|
||||
* is to be initialized.
|
||||
*/
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
// Bind event handler so it is only bound once for every instance.
|
||||
this._onClick = this._onClick.bind(this);
|
||||
this._onKeyPress = this._onKeyPress.bind(this);
|
||||
this._onEscKey = this._onEscKey.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* KeyPress handler for accessibility.
|
||||
*
|
||||
* @param {Object} e - The key event to handle.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onEscKey(e: React.KeyboardEvent) {
|
||||
// Escape handling does not work in onKeyPress
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.props.onSmileySelect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* KeyPress handler for accessibility.
|
||||
*
|
||||
* @param {Object} e - The key event to handle.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onKeyPress(e: React.KeyboardEvent<HTMLDivElement>) {
|
||||
if (e.key === ' ' || e.key === 'Enter') {
|
||||
e.preventDefault(); // @ts-ignore
|
||||
this.props.onSmileySelect(e.target.id && smileys[e.target.id]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Click handler for to select emoji.
|
||||
*
|
||||
* @param {Object} e - The key event to handle.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onClick(e: React.MouseEvent) {
|
||||
e.preventDefault();
|
||||
this.props.onSmileySelect(e.currentTarget.id && smileys[e.currentTarget.id as keyof typeof smileys]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#render()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
override render() {
|
||||
const smileyItems = Object.keys(smileys).map(smileyKey => (
|
||||
<div
|
||||
className = 'smileyContainer'
|
||||
id = { smileyKey }
|
||||
key = { smileyKey }
|
||||
onClick = { this._onClick }
|
||||
onKeyDown = { this._onEscKey }
|
||||
onKeyPress = { this._onKeyPress }
|
||||
role = 'option'
|
||||
tabIndex = { 0 }>
|
||||
<Tooltip content = { smileys[smileyKey as keyof typeof smileys] }>
|
||||
<Emoji
|
||||
onlyEmojiClassName = 'smiley'
|
||||
text = { smileys[smileyKey as keyof typeof smileys] } />
|
||||
</Tooltip>
|
||||
</div>
|
||||
));
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-orientation = 'horizontal'
|
||||
id = 'smileysContainer'
|
||||
onKeyDown = { this._onEscKey }
|
||||
role = 'listbox'
|
||||
tabIndex = { -1 }>
|
||||
{ smileyItems }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default SmileysPanel;
|
||||
96
react/features/chat/components/web/SubtitleMessage.tsx
Normal file
96
react/features/chat/components/web/SubtitleMessage.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import { getParticipantDisplayName } from '../../../base/participants/functions';
|
||||
import { ISubtitle } from '../../../subtitles/types';
|
||||
|
||||
/**
|
||||
* Props for the SubtitleMessage component.
|
||||
*/
|
||||
interface IProps extends ISubtitle {
|
||||
|
||||
/**
|
||||
* Whether to show the display name of the participant.
|
||||
*/
|
||||
showDisplayName: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The styles for the SubtitleMessage component.
|
||||
*/
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
messageContainer: {
|
||||
backgroundColor: theme.palette.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>
|
||||
);
|
||||
}
|
||||
76
react/features/chat/components/web/SubtitlesGroup.tsx
Normal file
76
react/features/chat/components/web/SubtitlesGroup.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import React from 'react';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import Avatar from '../../../base/avatar/components/Avatar';
|
||||
import { ISubtitle } from '../../../subtitles/types';
|
||||
|
||||
import SubtitleMessage from './SubtitleMessage';
|
||||
|
||||
/**
|
||||
* Props for the SubtitlesGroup component.
|
||||
*/
|
||||
interface IProps {
|
||||
|
||||
/**
|
||||
* Array of subtitle messages to be displayed in this group.
|
||||
*/
|
||||
messages: ISubtitle[];
|
||||
|
||||
/**
|
||||
* The ID of the participant who sent these subtitles.
|
||||
*/
|
||||
senderId: string;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
groupContainer: {
|
||||
display: 'flex',
|
||||
marginBottom: theme.spacing(3)
|
||||
},
|
||||
|
||||
avatar: {
|
||||
marginRight: theme.spacing(2),
|
||||
alignSelf: 'flex-start'
|
||||
},
|
||||
|
||||
messagesContainer: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flex: 1,
|
||||
maxWidth: 'calc(100% - 56px)', // 40px avatar + 16px margin
|
||||
gap: theme.spacing(1)
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Component that renders a group of subtitle messages from the same sender.
|
||||
*
|
||||
* @param {IProps} props - The props for the component.
|
||||
* @returns {JSX.Element} - A React component rendering a group of subtitles.
|
||||
*/
|
||||
export function SubtitlesGroup({ messages, senderId }: IProps) {
|
||||
const { classes } = useStyles();
|
||||
|
||||
if (!messages.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className = { classes.groupContainer }>
|
||||
<Avatar
|
||||
className = { classes.avatar }
|
||||
participantId = { senderId }
|
||||
size = { 32 } />
|
||||
<div className = { classes.messagesContainer }>
|
||||
{messages.map((message, index) => (
|
||||
<SubtitleMessage
|
||||
key = { `${message.timestamp}-${message.id}` }
|
||||
showDisplayName = { index === 0 }
|
||||
{ ...message } />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user