import { throttle } from 'lodash-es'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { connect, useSelector } from 'react-redux'; import { makeStyles } from 'tss-react/mui'; import { IReduxState } from '../../../app/types'; import { translate } from '../../../base/i18n/functions'; import { IconInfo, IconMessage, IconShareDoc, IconSubtitles } from '../../../base/icons/svg'; import { getLocalParticipant, getRemoteParticipants } from '../../../base/participants/functions'; import Select from '../../../base/ui/components/web/Select'; import Tabs from '../../../base/ui/components/web/Tabs'; import { arePollsDisabled } from '../../../conference/functions.any'; import FileSharing from '../../../file-sharing/components/web/FileSharing'; import { isFileSharingEnabled } from '../../../file-sharing/functions.any'; import PollsPane from '../../../polls/components/web/PollsPane'; import { isCCTabEnabled } from '../../../subtitles/functions.any'; import { sendMessage, setChatIsResizing, setFocusedTab, setPrivateMessageRecipient, setPrivateMessageRecipientById, setUserChatWidth, toggleChat } from '../../actions.web'; import { CHAT_SIZE, ChatTabs, OPTION_GROUPCHAT, SMALL_WIDTH_THRESHOLD } from '../../constants'; import { getChatMaxSize } from '../../functions'; import { IChatProps as AbstractProps } from '../../types'; import ChatHeader from './ChatHeader'; import ChatInput from './ChatInput'; import ClosedCaptionsTab from './ClosedCaptionsTab'; import DisplayNameForm from './DisplayNameForm'; import KeyboardAvoider from './KeyboardAvoider'; import MessageContainer from './MessageContainer'; import MessageRecipient from './MessageRecipient'; interface IProps extends AbstractProps { /** * The currently focused tab. */ _focusedTab: ChatTabs; /** * True if the CC tab is enabled and false otherwise. */ _isCCTabEnabled: boolean; /** * True if file sharing tab is enabled. */ _isFileSharingTabEnabled: boolean; /** * Whether the chat is opened in a modal or not (computed based on window width). */ _isModal: boolean; /** * True if the chat window should be rendered. */ _isOpen: boolean; /** * True if the polls feature is enabled. */ _isPollsEnabled: boolean; /** * Whether the user is currently resizing the chat panel. */ _isResizing: boolean; /** * Number of unread poll messages. */ _nbUnreadPolls: number; /** * Function to send a text message. * * @protected */ _onSendMessage: Function; /** * Function to toggle the chat window. */ _onToggleChat: Function; /** * Function to display the chat tab. * * @protected */ _onToggleChatTab: Function; /** * Function to display the polls tab. * * @protected */ _onTogglePollsTab: Function; /** * Whether or not to block chat access with a nickname input form. */ _showNamePrompt: boolean; /** * The current width of the chat panel. */ _width: number; } const useStyles = makeStyles<{ _isResizing: boolean; width: number; }>()((theme, { _isResizing, width }) => { return { container: { backgroundColor: theme.palette.ui01, flexShrink: 0, overflow: 'hidden', position: 'relative', transition: _isResizing ? undefined : 'width .16s ease-in-out', width: `${width}px`, zIndex: 300, '&:hover, &:focus-within': { '& .dragHandleContainer': { visibility: 'visible' } }, '@media (max-width: 580px)': { height: '100dvh', position: 'fixed', left: 0, right: 0, top: 0, width: 'auto' }, '*': { userSelect: 'text', '-webkit-user-select': 'text' } }, chatHeader: { height: '60px', position: 'relative', width: '100%', zIndex: 1, display: 'flex', justifyContent: 'space-between', padding: `${theme.spacing(3)} ${theme.spacing(4)}`, alignItems: 'center', boxSizing: 'border-box', color: theme.palette.text01, ...theme.typography.heading6, lineHeight: 'unset', fontWeight: theme.typography.heading6.fontWeight as any, '.jitsi-icon': { cursor: 'pointer' } }, chatPanel: { display: 'flex', flexDirection: 'column', // extract header + tabs height height: 'calc(100% - 110px)' }, chatPanelNoTabs: { // extract header height height: 'calc(100% - 60px)' }, pollsPanel: { // extract header + tabs height height: 'calc(100% - 110px)' }, resizableChat: { flex: 1, display: 'flex', flexDirection: 'column', width: '100%' }, dragHandleContainer: { height: '100%', width: '9px', backgroundColor: 'transparent', position: 'absolute', cursor: 'col-resize', display: 'flex', alignItems: 'center', justifyContent: 'center', visibility: 'hidden', right: '4px', top: 0, '&:hover': { '& .dragHandle': { backgroundColor: theme.palette.icon01 } }, '&.visible': { visibility: 'visible', '& .dragHandle': { backgroundColor: theme.palette.icon01 } } }, dragHandle: { backgroundColor: theme.palette.icon02, height: '100px', width: '3px', borderRadius: '1px' }, privateMessageRecipientsList: { padding: '0 16px 5px' } }; }); const Chat = ({ _isModal, _isOpen, _isPollsEnabled, _isCCTabEnabled, _isFileSharingTabEnabled, _focusedTab, _isResizing, _messages, _nbUnreadMessages, _nbUnreadPolls, _onSendMessage, _onToggleChat, _onToggleChatTab, _onTogglePollsTab, _showNamePrompt, _width, dispatch, t }: IProps) => { const { classes, cx } = useStyles({ _isResizing, width: _width }); const [ isMouseDown, setIsMouseDown ] = useState(false); const [ mousePosition, setMousePosition ] = useState(null); const [ dragChatWidth, setDragChatWidth ] = useState(null); const maxChatWidth = useSelector(getChatMaxSize); const notifyTimestamp = useSelector((state: IReduxState) => state['features/chat'].notifyPrivateRecipientsChangedTimestamp ); const { defaultRemoteDisplayName = 'Fellow Jitster' } = useSelector((state: IReduxState) => state['features/base/config']); const privateMessageRecipient = useSelector((state: IReduxState) => state['features/chat'].privateMessageRecipient); const participants = useSelector(getRemoteParticipants); const options = useMemo(() => { const o = Array.from(participants?.values() || []) .filter(p => !p.fakeParticipant) .map(p => { return { value: p.id, label: p.name ?? defaultRemoteDisplayName }; }); o.sort((a, b) => a.label.localeCompare(b.label)); o.unshift({ label: t('chat.everyone'), value: OPTION_GROUPCHAT }); return o; }, [ participants, defaultRemoteDisplayName, t, notifyTimestamp ]); /** * Handles mouse down on the drag handle. * * @param {MouseEvent} e - The mouse down event. * @returns {void} */ const onDragHandleMouseDown = useCallback((e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); // Store the initial mouse position and chat width setIsMouseDown(true); setMousePosition(e.clientX); setDragChatWidth(_width); // Indicate that resizing is in progress dispatch(setChatIsResizing(true)); // Add visual feedback that we're dragging document.body.style.cursor = 'col-resize'; // Disable text selection during resize document.body.style.userSelect = 'none'; console.log('Chat resize: Mouse down', { clientX: e.clientX, initialWidth: _width }); }, [ _width, dispatch ]); /** * Drag handle mouse up handler. * * @returns {void} */ const onDragMouseUp = useCallback(() => { if (isMouseDown) { setIsMouseDown(false); dispatch(setChatIsResizing(false)); // Restore cursor and text selection document.body.style.cursor = ''; document.body.style.userSelect = ''; console.log('Chat resize: Mouse up'); } }, [ isMouseDown, dispatch ]); /** * Handles drag handle mouse move. * * @param {MouseEvent} e - The mousemove event. * @returns {void} */ const onChatResize = useCallback(throttle((e: MouseEvent) => { // console.log('Chat resize: Mouse move', { clientX: e.clientX, isMouseDown, mousePosition, _width }); if (isMouseDown && mousePosition !== null && dragChatWidth !== null) { // For chat panel resizing on the left edge: // - Dragging left (decreasing X coordinate) should make the panel wider // - Dragging right (increasing X coordinate) should make the panel narrower const diff = e.clientX - mousePosition; const newWidth = Math.max( Math.min(dragChatWidth + diff, maxChatWidth), CHAT_SIZE ); // Update the width only if it has changed if (newWidth !== _width) { dispatch(setUserChatWidth(newWidth)); } } }, 50, { leading: true, trailing: false }), [ isMouseDown, mousePosition, dragChatWidth, _width, maxChatWidth, dispatch ]); // Set up event listeners when component mounts useEffect(() => { document.addEventListener('mouseup', onDragMouseUp); document.addEventListener('mousemove', onChatResize); return () => { document.removeEventListener('mouseup', onDragMouseUp); document.removeEventListener('mousemove', onChatResize); }; }, [ onDragMouseUp, onChatResize ]); /** * Sends a text message. * * @private * @param {string} text - The text message to be sent. * @returns {void} * @type {Function} */ const onSendMessage = useCallback((text: string) => { dispatch(sendMessage(text)); }, []); /** * Toggles the chat window. * * @returns {Function} */ const onToggleChat = useCallback(() => { dispatch(toggleChat()); }, []); /** * Click handler for the chat sidenav. * * @param {KeyboardEvent} event - Esc key click to close the popup. * @returns {void} */ const onEscClick = useCallback((event: React.KeyboardEvent) => { if (event.key === 'Escape' && _isOpen) { event.preventDefault(); event.stopPropagation(); onToggleChat(); } }, [ _isOpen ]); /** * Change selected tab. * * @param {string} id - Id of the clicked tab. * @returns {void} */ const onChangeTab = useCallback((id: string) => { dispatch(setFocusedTab(id as ChatTabs)); }, [ dispatch ]); const onSelectedRecipientChange = useCallback((e: React.ChangeEvent) => { 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()}