import { Popover } from 'antd'; import React, { FC, useEffect, useReducer, useRef, useState } from 'react'; import { useRecoilValue } from 'recoil'; import ContentEditable from 'react-contenteditable'; import sanitizeHtml from 'sanitize-html'; import dynamic from 'next/dynamic'; import classNames from 'classnames'; import WebsocketService from '../../../services/websocket-service'; import { websocketServiceAtom } from '../../stores/ClientConfigStore'; import { MessageType } from '../../../interfaces/socket-events'; import styles from './ChatTextField.module.scss'; // Lazy loaded components const EmojiPicker = dynamic(() => import('./EmojiPicker').then(mod => mod.EmojiPicker), { ssr: false, }); const SendOutlined = dynamic(() => import('@ant-design/icons/SendOutlined'), { ssr: false, }); const SmileOutlined = dynamic(() => import('@ant-design/icons/SmileOutlined'), { ssr: false, }); export type ChatTextFieldProps = { defaultText?: string; enabled: boolean; focusInput: boolean; }; const characterLimit = 300; function getCaretPosition(node) { const selection = window.getSelection(); if (selection.rangeCount === 0) { return 0; } const range = selection.getRangeAt(0); const preCaretRange = range.cloneRange(); const tempElement = document.createElement('div'); preCaretRange.selectNodeContents(node); preCaretRange.setEnd(range.endContainer, range.endOffset); tempElement.appendChild(preCaretRange.cloneContents()); return tempElement.innerHTML.length; } function setCaretPosition(editableDiv, position) { try { const range = document.createRange(); const sel = window.getSelection(); range.selectNode(editableDiv); range.setStart(editableDiv.childNodes[0], position); range.collapse(true); sel.removeAllRanges(); sel.addRange(range); } catch (e) { console.debug(e); } } export const ChatTextField: FC = ({ defaultText, enabled, focusInput }) => { const [characterCount, setCharacterCount] = useState(defaultText?.length); const websocketService = useRecoilValue(websocketServiceAtom); const text = useRef(defaultText || ''); const [savedCursorLocation, setSavedCursorLocation] = useState(0); const [customEmoji, setCustomEmoji] = useState([]); // This is a bit of a hack to force the component to re-render when the text changes. // By default when updating a ref the component doesn't re-render. const [, forceUpdate] = useReducer(x => x + 1, 0); const getCharacterCount = () => text.current.length; const sendMessage = () => { if (!websocketService) { console.log('websocketService is not defined'); return; } let message = text.current; // Strip the opening and closing

tags. message = message.replace(/^

|<\/p>$/g, ''); websocketService.send({ type: MessageType.CHAT, body: message }); // Clear the input. text.current = ''; setCharacterCount(0); forceUpdate(); }; const insertTextAtEnd = (textToInsert: string) => { const output = text.current + textToInsert; text.current = output; forceUpdate(); }; // Native emoji const onEmojiSelect = (emoji: string) => { insertTextAtEnd(emoji); }; // Custom emoji images const onCustomEmojiSelect = (name: string, emoji: string) => { const html = `${name}`; insertTextAtEnd(html); }; const onKeyDown = (e: React.KeyboardEvent) => { // Allow native line breaks if (e.key === 'Enter' && e.shiftKey) { return; } const charCount = getCharacterCount() + 1; // Always allow backspace. if (e.key === 'Backspace') { return; } // Always allow delete. if (e.key === 'Delete') { return; } // Always allow ctrl + a. if (e.key === 'a' && e.ctrlKey) { return; } // Limit the number of characters. if (charCount + 1 > characterLimit) { e.preventDefault(); return; } // Send the message when hitting enter. if (e.key === 'Enter') { e.preventDefault(); sendMessage(); return; } setCharacterCount(charCount + 1); }; const handleChange = evt => { const sanitized = sanitizeHtml(evt.target.value, { allowedTags: ['b', 'i', 'em', 'strong', 'a', 'br', 'p', 'img'], allowedAttributes: { img: ['class', 'alt', 'title', 'src'], }, allowedClasses: { img: ['emoji'], }, transformTags: { h1: 'p', h2: 'p', h3: 'p', }, }); text.current = sanitized; const charCountString = sanitized.replace(/<\/?[^>]+(>|$)/g, ''); setCharacterCount(charCountString.length); setSavedCursorLocation( getCaretPosition(document.getElementById('chat-input-content-editable')), ); }; const handleBlur = () => { // Save the cursor location. setSavedCursorLocation( getCaretPosition(document.getElementById('chat-input-content-editable')), ); }; const handleFocus = () => { if (!savedCursorLocation) { return; } // Restore the cursor location. setCaretPosition(document.getElementById('chat-input-content-editable'), savedCursorLocation); setSavedCursorLocation(0); }; // Focus the input when the component mounts. useEffect(() => { if (!focusInput) { return; } document.getElementById('chat-input-content-editable').focus(); }, []); const getCustomEmoji = async () => { try { const response = await fetch(`/api/emoji`); const emoji = await response.json(); setCustomEmoji(emoji); emoji.forEach(e => { const preImg = document.createElement('link'); preImg.href = e.url; preImg.rel = 'preload'; preImg.as = 'image'; document.head.appendChild(preImg); }); } catch (e) { console.error('cannot fetch custom emoji', e); } }; useEffect(() => { getCustomEmoji(); }, []); return (

= characterLimit && styles.maxCharacters, )} > {enabled && (
} trigger="click" placement="topRight" >
)}
); };