import { encode } from 'html-entities'; import { motion } from 'framer-motion'; import { useNavigate, useLocation } from 'react-router-dom'; import { useState, useEffect, useRef } from 'react'; import PropTypes from 'prop-types'; import { COMPOSER_MAX_CHARACTER_LENGTH, COMPOSER_RATE_LIMIT_BLOCK_TIME_MS } from '../../../constants'; import { CHAT_MESSAGE_EVENT_TYPES } from '../../../constants'; import { channel as $channelContent } from '../../../content'; import { CHAT_USER_ROLE, SEND_ERRORS } from './useChatConnection/utils'; import { clsm } from '../../../utils'; import { Lock } from '../../../assets/icons'; import { useChannel } from '../../../contexts/Channel'; import { useResponsiveDevice } from '../../../contexts/ResponsiveDevice'; import { useUser } from '../../../contexts/User'; import ComposerErrorMessage from './ComposerErrorMessage'; import FloatingNav from '../../../components/FloatingNav'; import Input from '../../../components/Input'; import useCurrentPage from '../../../hooks/useCurrentPage'; import { usePoll } from '../../../contexts/StreamManagerActions/Poll'; const { SEND_MESSAGE } = CHAT_MESSAGE_EVENT_TYPES; const $content = $channelContent.chat; const Composer = ({ chatUserRole, isDisabled, isFocusable, isLoading, sendAttemptError, sendMessage }) => { const navigate = useNavigate(); const location = useLocation(); const composerFieldRef = useRef(); const { channelData } = useChannel(); const { isViewerBanned: isLocked } = channelData || {}; const { isLandscape } = useResponsiveDevice(); const { isSessionValid } = useUser(); const { setComposerRefState } = usePoll(); const [message, setMessage] = useState(''); const [errorMessage, setErrorMessage] = useState(''); const [shouldShake, setShouldShake] = useState(false); // Composer has shake animated only on submit const [blockChat, setBlockChat] = useState(false); const currentPage = useCurrentPage(); const isStreamManagerPage = currentPage === 'stream_manager'; const canSendMessages = chatUserRole && [CHAT_USER_ROLE.SENDER, CHAT_USER_ROLE.MODERATOR].includes(chatUserRole); const focus = location.state?.focus; useEffect(() => { if (composerFieldRef.current) { setComposerRefState(composerFieldRef); } }, [composerFieldRef, setComposerRefState]); const setSubmitErrorStates = (_errorMessage) => { setErrorMessage(`${$content.error.message_not_sent} ${_errorMessage}`); setShouldShake(true); }; const navigateToLogin = () => navigate('/login', { state: { from: location, focus: 'COMPOSER' } }); const handleOnChange = (event) => { // If the user isn't logged in, redirect them to the login page if ((!isLoading && canSendMessages === false) || !isSessionValid) { navigateToLogin(); } const { value } = event.target; const encodedValue = encode(value); // This is done to ensure we get the correct message length as it seems the IVS Chat API trims the message before checking its length const trimmedValue = encodedValue.trim(); setMessage(value); // On change errors if (trimmedValue.length > COMPOSER_MAX_CHARACTER_LENGTH) { setErrorMessage($content.error.max_length_reached); } else if (!blockChat) { setErrorMessage(''); } }; const handleSendMessage = (event) => { event.preventDefault(); if (isDisabled) return; if (isLoading) { setSubmitErrorStates($content.error.wait_until_connected); } else { if (canSendMessages) { if (!message || blockChat) return; if (errorMessage.includes($content.error.wait_until_connected)) { setErrorMessage(''); setMessage(''); } sendMessage(message, { eventType: SEND_MESSAGE }); !errorMessage && setMessage(''); setShouldShake(false); } else { if (canSendMessages === undefined) { setSubmitErrorStates($content.error.wait_until_connected); } else { navigateToLogin(); } } } }; useEffect(() => { // If previous route has focus state, focus on composer if (focus && focus === 'COMPOSER') { composerFieldRef.current.focus(); } }, [focus]); useEffect(() => { // If user is banned, remove any message if (isLocked) setMessage(''); }, [isLocked]); useEffect(() => { // If there is a connection error, clear the current error message if (isDisabled) setErrorMessage(''); }, [isDisabled]); useEffect(() => { if (blockChat) { const blockChatTimerId = setTimeout(() => { setBlockChat(false); setErrorMessage(''); }, COMPOSER_RATE_LIMIT_BLOCK_TIME_MS); return () => clearTimeout(blockChatTimerId); } }, [blockChat]); useEffect(() => { // Send errors if (sendAttemptError) { let sendAttemptErrorMessage = ''; if (sendAttemptError.message === SEND_ERRORS.RATE_LIMIT_EXCEEDED) { setBlockChat(true); sendAttemptErrorMessage = $content.error.rate_exceeded; } else if (sendAttemptError.message === SEND_ERRORS.MAX_LENGTH_EXCEEDED) { sendAttemptErrorMessage = $content.error.max_length_reached; } // connection error or chat is loading (chat is not connected) setSubmitErrorStates(sendAttemptErrorMessage); } }, [sendAttemptError, isLoading]); return ( <div className={clsm(['w-full', 'pt-5', 'pb-6', 'px-[18px]'])}> <motion.div animate={shouldShake ? 'shake' : 'default'} variants={{ shake: { x: [12, -12, 8, -8, 4, 0] }, default: { x: 0 } }} transition={{ duration: 0.5 }} > <form className={clsm( 'relative', isSessionValid && [ 'md:w-[calc(100%_-_60px)]', isLandscape && 'touch-screen-device:lg:w-[calc(100%_-_60px)]' ] )} onSubmit={handleSendMessage} > <div> <ComposerErrorMessage errorMessage={errorMessage} /> <Input {...(!isFocusable ? { tabIndex: -1 } : {})} ariaLabel={isDisabled ? 'Chat disabled' : null} autoComplete="off" className={clsm( [ 'bg-lightMode-gray', 'dark:bg-darkMode-gray', 'dark:focus:text-white', 'dark:hover:bg-darkMode-gray-hover', 'dark:hover:placeholder-white', 'dark:hover:text-white', 'dark:placeholder-darkMode-gray-light', 'focus:bg-darkMode-gray-medium', 'h-12', 'placeholder-lightMode-gray-dark' ], errorMessage && [ 'dark:focus:shadow-darkMode-red', 'dark:focus:shadow-focus', 'dark:shadow-darkMode-red', 'focus:shadow-lightMode-red', 'rounded-b-3xl', 'rounded-t-none', 'shadow-lightMode-red' ], isLocked && [ 'dark:read-only:focus:bg-darkMode-gray', 'dark:read-only:hover:bg-darkMode-gray', 'dark:read-only:hover:placeholder-darkMode-gray-light', 'pr-[60px]', 'read-only:cursor-not-allowed', 'read-only:focus:bg-lightMode-gray', 'read-only:focus:shadow-none', 'read-only:hover:bg-lightMode-gray', 'read-only:hover:placeholder-lightMode-gray-dark', 'read-only:hover:placeholder-lightMode-gray-medium' ], isDisabled && ['opacity-30'] )} error={errorMessage ? '' : null} isRequired={false} name="chatComposer" onChange={handleOnChange} placeholder={ isLocked ? $content.you_are_banned : $content.say_something } readOnly={isDisabled || isLocked} ref={composerFieldRef} value={message} /> {isLocked && ( <span className={clsm([ '[&>svg]:fill-lightMode-gray-medium', '[&>svg]:h-6', '[&>svg]:w-6', 'absolute', 'bottom-3', 'cursor-not-allowed', 'dark:[&>svg]:fill-darkMode-gray-light', 'dark:fill-darkMode-gray-light', 'right-6', 'top-3' ])} > <Lock /> </span> )} </div> </form> </motion.div> <FloatingNav {...(isStreamManagerPage && { containerClassName: (isOpen) => clsm([ 'max-h-[min(650px,calc(calc(var(--mobile-vh,1vh)_*_100)_-_72px))]', isOpen && ['bottom-12', 'right-[52px]', 'sm:right-9'] ]), menuClassName: clsm([ 'lg:w-[calc(100vw_-_104px)]', 'sm:w-[calc(100vw_-_72px)]', isLandscape && [ 'fixed', 'max-h-[min(570px,calc(calc(var(--mobile-vh,1vh)_*_100)_-_126px))]', 'right-[52px]', 'sm:right-[36px]', 'sm:w-full', 'max-w-[400px]', 'bottom-[112px]', 'sm:w-[calc(100vw_-_72px)]' ] ]) })} /> </div> ); }; Composer.defaultProps = { chatUserRole: undefined, isDisabled: false, isFocusable: true, isLoading: true, sendAttemptError: null }; Composer.propTypes = { chatUserRole: PropTypes.oneOf(Object.values(CHAT_USER_ROLE)), isDisabled: PropTypes.bool, isFocusable: PropTypes.bool, isLoading: PropTypes.bool, sendAttemptError: PropTypes.shape({ message: PropTypes.string }), sendMessage: PropTypes.func.isRequired }; export default Composer;