import { Popover } from 'antd'; import React, { FC, useEffect, useState } from 'react'; import { useRecoilValue } from 'recoil'; import sanitizeHtml from 'sanitize-html'; import GraphemeSplitter from 'grapheme-splitter'; import dynamic from 'next/dynamic'; import classNames from 'classnames'; import ContentEditable from './ContentEditable'; 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; const maxNodeDepth = 10; const graphemeSplitter = new GraphemeSplitter(); const getNodeTextContent = (node, depth) => { let text = ''; if (depth > maxNodeDepth) return text; if (node === null) return text; switch (node.nodeType) { case Node.CDATA_SECTION_NODE: // unlikely case Node.TEXT_NODE: { text = node.nodeValue; break; } case Node.ELEMENT_NODE: { switch (node.tagName.toLowerCase()) { case 'img': { text = node.getAttribute('alt') || ''; break; } case 'br': { text = '\n'; break; } case 'strong': case 'b': { /* markdown representation of bold/strong */ text = '**'; for (let i = 0; i < node.childNodes.length; i += 1) { text += getNodeTextContent(node.childNodes[i], depth + 1); } text += '**'; break; } case 'em': case 'i': { /* markdown representation of italic/emphasis */ text = '*'; for (let i = 0; i < node.childNodes.length; i += 1) { text += getNodeTextContent(node.childNodes[i], depth + 1); } text += '*'; break; } case 'p': { text = '\n'; for (let i = 0; i < node.childNodes.length; i += 1) { text += getNodeTextContent(node.childNodes[i], depth + 1); } break; } case 'a': case 'span': case 'div': { for (let i = 0; i < node.childNodes.length; i += 1) { text += getNodeTextContent(node.childNodes[i], depth + 1); } break; } /* nodes which should specifically not be parsed */ case 'script': case 'style': { break; } default: { text = node.textContent; break; } } break; } default: break; } return text; }; const getTextContent = node => { const text = getNodeTextContent(node, 0) .replace(/^\s+/, '') /* remove leading whitespace */ .replace(/\s+$/, '') /* remove trailing whitespace */ .replace(/\n([^\n])/g, ' \n$1'); /* single line break to markdown break */ return text; }; export const ChatTextField: FC = ({ defaultText, enabled, focusInput }) => { const [characterCount, setCharacterCount] = useState(defaultText?.length); const websocketService = useRecoilValue(websocketServiceAtom); const [contentEditable, setContentEditable] = useState(null); const [customEmoji, setCustomEmoji] = useState([]); const onRootRef = el => { setContentEditable(el); }; const getCharacterCount = () => { const message = getTextContent(contentEditable); return graphemeSplitter.countGraphemes(message); }; const sendMessage = () => { if (!websocketService) { console.log('websocketService is not defined'); return; } const message = getTextContent(contentEditable); const count = graphemeSplitter.countGraphemes(message); if (count === 0 || count > characterLimit) return; websocketService.send({ type: MessageType.CHAT, body: message }); contentEditable.innerHTML = ''; }; const insertTextAtEnd = (textToInsert: string) => { contentEditable.innerHTML += textToInsert; }; // 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) => { if (e.key === 'Enter' && !(e.shiftKey || e.metaKey || e.ctrlKey || e.altKey)) { e.preventDefault(); sendMessage(); } }; const onPaste = evt => { evt.preventDefault(); const clip = evt.clipboardData; const { types } = clip; const contentTypes = ['text/html', 'text/plain']; let content; for (let i = 0; i < contentTypes.length; i += 1) { const contentType = contentTypes[i]; if (types.includes(contentType)) { content = clip.getData(contentType); break; } } if (!content) return; const sanitized = sanitizeHtml(content, { 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', }, }); // MDN lists this as deprecated, but it's the only way to save this paste // into the browser's Undo buffer. Plus it handles all the selection // deletion, caret positioning, etc automaticaly. if (sanitized) document.execCommand('insertHTML', false, sanitized); }; const handleChange = () => { const count = getCharacterCount(); setCharacterCount(count); if (count === 0 && contentEditable.children.length === 1) { /* if we have a single
element added by the browser, remove. */ if (contentEditable.children[0].tagName.toLowerCase() === 'br') { contentEditable.removeChild(contentEditable.children[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" >
)}
); };