0

feat(chat): support line breaks and pasted content. Closes #3108

This commit is contained in:
Gabe Kangas 2023-06-27 14:45:45 -07:00
parent bd6e263eb9
commit a354787a9e
No known key found for this signature in database
GPG Key ID: 4345B2060657F330
7 changed files with 239 additions and 92 deletions

View File

@ -149,7 +149,7 @@ func sanitize(raw string) string {
// Allow breaks // Allow breaks
p.AllowElements("br") p.AllowElements("br")
p.AllowElementsContent("p") p.AllowElements("p")
// Allow img tags from the the local emoji directory only // Allow img tags from the the local emoji directory only
p.AllowAttrs("src").Matching(_sanitizeReSrcMatch).OnElements("img") p.AllowAttrs("src").Matching(_sanitizeReSrcMatch).OnElements("img")

View File

@ -16,11 +16,11 @@ Here is an iframe<iframe src="http://yahoo.com"></iframe>
[test link](http://owncast.online) [test link](http://owncast.online)
<img class="emoji" src="/img/emoji/bananadance.gif">` <img class="emoji" src="/img/emoji/bananadance.gif">`
expected := `Test one two three! I go to <a href="http://yahoo.com" rel="nofollow noreferrer noopener" target="_blank">http://yahoo.com</a> and search for <em>sports</em> and <strong>answers</strong>. expected := `<p>Test one two three! I go to <a href="http://yahoo.com" rel="nofollow noreferrer noopener" target="_blank">http://yahoo.com</a> and search for <em>sports</em> and <strong>answers</strong>.
Here is an iframe Here is an iframe</p>
blah blah blah blah blah blah
<a href="http://owncast.online" rel="nofollow noreferrer noopener" target="_blank">test link</a> <p><a href="http://owncast.online" rel="nofollow noreferrer noopener" target="_blank">test link</a>
<img class="emoji" src="/img/emoji/bananadance.gif">` <img class="emoji" src="/img/emoji/bananadance.gif"></p>`
result := events.RenderAndSanitize(messageContent) result := events.RenderAndSanitize(messageContent)
if result != expected { if result != expected {
@ -31,7 +31,7 @@ blah blah blah
// Test to make sure we block remote images in chat messages. // Test to make sure we block remote images in chat messages.
func TestBlockRemoteImages(t *testing.T) { func TestBlockRemoteImages(t *testing.T) {
messageContent := `<img src="https://via.placeholder.com/img/emoji/350x150"> test ![](https://via.placeholder.com/img/emoji/350x150)` messageContent := `<img src="https://via.placeholder.com/img/emoji/350x150"> test ![](https://via.placeholder.com/img/emoji/350x150)`
expected := `test` expected := `<p> test </p>`
result := events.RenderAndSanitize(messageContent) result := events.RenderAndSanitize(messageContent)
if result != expected { if result != expected {
@ -42,7 +42,7 @@ func TestBlockRemoteImages(t *testing.T) {
// Test to make sure emoji images are allowed in chat messages. // Test to make sure emoji images are allowed in chat messages.
func TestAllowEmojiImages(t *testing.T) { func TestAllowEmojiImages(t *testing.T) {
messageContent := `<img alt=":beerparrot:" title=":beerparrot:" src="/img/emoji/beerparrot.gif"> test ![](/img/emoji/beerparrot.gif)` messageContent := `<img alt=":beerparrot:" title=":beerparrot:" src="/img/emoji/beerparrot.gif"> test ![](/img/emoji/beerparrot.gif)`
expected := `<img alt=":beerparrot:" title=":beerparrot:" src="/img/emoji/beerparrot.gif"> test <img src="/img/emoji/beerparrot.gif">` expected := `<p><img alt=":beerparrot:" title=":beerparrot:" src="/img/emoji/beerparrot.gif"> test <img src="/img/emoji/beerparrot.gif"></p>`
result := events.RenderAndSanitize(messageContent) result := events.RenderAndSanitize(messageContent)
if result != expected { if result != expected {

View File

@ -1,6 +1,7 @@
{ {
"extends": "stylelint-config-standard-scss", "extends": "stylelint-config-standard-scss",
"rules": { "rules": {
"selector-class-pattern": null "selector-class-pattern": null,
"no-descending-specificity": null
} }
} }

View File

@ -1,7 +1,9 @@
import { Popover } from 'antd'; import { Popover } from 'antd';
import React, { FC, useReducer, useRef, useState } from 'react'; import React, { FC, useEffect, useReducer, useRef, useState } from 'react';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import ContentEditable from 'react-contenteditable'; import ContentEditable from 'react-contenteditable';
import sanitizeHtml from 'sanitize-html';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import classNames from 'classnames'; import classNames from 'classnames';
import WebsocketService from '../../../services/websocket-service'; import WebsocketService from '../../../services/websocket-service';
@ -64,43 +66,6 @@ function setCaretPosition(editableDiv, position) {
} }
} }
function convertToText(str = '') {
// Ensure string.
let value = String(str);
// Convert encoding.
value = value.replace(/&nbsp;/gi, ' ');
value = value.replace(/&amp;/gi, '&');
// Replace `<br>`.
value = value.replace(/<br>/gi, '\n');
// Replace `<div>` (from Chrome).
value = value.replace(/<div>/gi, '\n');
// Replace `<p>` (from IE).
value = value.replace(/<p>/gi, '\n');
// Cleanup the emoji titles.
value = value.replace(/\u200C{2}/gi, '');
// Trim each line.
value = value
.split('\n')
.map((line = '') => line.trim())
.join('\n');
// No more than 2x newline, per "paragraph".
value = value.replace(/\n\n+/g, '\n\n');
// Clean up spaces.
value = value.replace(/[ ]+/g, ' ');
value = value.trim();
// Expose string.
return value;
}
export const ChatTextField: FC<ChatTextFieldProps> = ({ defaultText, enabled, focusInput }) => { export const ChatTextField: FC<ChatTextFieldProps> = ({ defaultText, enabled, focusInput }) => {
const [showEmojis, setShowEmojis] = useState(false); const [showEmojis, setShowEmojis] = useState(false);
const [characterCount, setCharacterCount] = useState(defaultText?.length); const [characterCount, setCharacterCount] = useState(defaultText?.length);
@ -132,44 +97,21 @@ export const ChatTextField: FC<ChatTextFieldProps> = ({ defaultText, enabled, fo
}; };
const insertTextAtCursor = (textToInsert: string) => { const insertTextAtCursor = (textToInsert: string) => {
const output = [ let cursorLocation;
text.current.slice(0, savedCursorLocation), if (savedCursorLocation > 0) {
textToInsert, cursorLocation = savedCursorLocation;
text.current.slice(savedCursorLocation), } else {
].join(''); cursorLocation = getCaretPosition(document.getElementById('chat-input'));
text.current = output;
forceUpdate();
};
const convertOnPaste = (event: React.ClipboardEvent) => {
// Prevent paste.
event.preventDefault();
// Set later.
let value = '';
// Does method exist?
const hasEventClipboard = !!(
event.clipboardData &&
typeof event.clipboardData === 'object' &&
typeof event.clipboardData.getData === 'function'
);
// Get clipboard data?
if (hasEventClipboard) {
value = event.clipboardData.getData('text/plain');
} }
// Insert into temp `<textarea>`, read back out. const output = [
const textarea = document.createElement('textarea'); text.current.slice(0, cursorLocation),
textarea.innerHTML = value; textToInsert,
value = textarea.innerText; text.current.slice(cursorLocation),
].join('');
// Clean up text. text.current = output;
value = convertToText(value); forceUpdate();
// Insert text.
insertTextAtCursor(value);
}; };
// Native emoji // Native emoji
@ -184,14 +126,13 @@ export const ChatTextField: FC<ChatTextFieldProps> = ({ defaultText, enabled, fo
}; };
const onKeyDown = (e: React.KeyboardEvent) => { const onKeyDown = (e: React.KeyboardEvent) => {
const charCount = getCharacterCount() + 1; // Allow native line breaks
if (e.key === 'Enter' && e.shiftKey) {
// Send the message when hitting enter.
if (e.key === 'Enter') {
e.preventDefault();
sendMessage();
return; return;
} }
const charCount = getCharacterCount() + 1;
// Always allow backspace. // Always allow backspace.
if (e.key === 'Backspace') { if (e.key === 'Backspace') {
setCharacterCount(charCount - 1); setCharacterCount(charCount - 1);
@ -213,11 +154,36 @@ export const ChatTextField: FC<ChatTextFieldProps> = ({ defaultText, enabled, fo
if (charCount + 1 > characterLimit) { if (charCount + 1 > characterLimit) {
e.preventDefault(); e.preventDefault();
} }
// Send the message when hitting enter.
if (e.key === 'Enter') {
e.preventDefault();
sendMessage();
return;
}
setCharacterCount(charCount + 1); setCharacterCount(charCount + 1);
}; };
const handleChange = evt => { const handleChange = evt => {
text.current = evt.target.value; 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;
setSavedCursorLocation(
getCaretPosition(document.getElementById('chat-input-content-editable')),
);
}; };
const handleBlur = () => { const handleBlur = () => {
@ -237,6 +203,14 @@ export const ChatTextField: FC<ChatTextFieldProps> = ({ defaultText, enabled, fo
setSavedCursorLocation(0); setSavedCursorLocation(0);
}; };
// Focus the input when the component mounts.
useEffect(() => {
if (!focusInput) {
return;
}
document.getElementById('chat-input-content-editable').focus();
}, []);
return ( return (
<div id="chat-input" className={styles.root}> <div id="chat-input" className={styles.root}>
<div <div
@ -260,11 +234,9 @@ export const ChatTextField: FC<ChatTextFieldProps> = ({ defaultText, enabled, fo
placeholder={enabled ? 'Send a message to chat' : 'Chat is disabled'} placeholder={enabled ? 'Send a message to chat' : 'Chat is disabled'}
disabled={!enabled} disabled={!enabled}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
onPaste={convertOnPaste}
onChange={handleChange} onChange={handleChange}
onBlur={handleBlur} onBlur={handleBlur}
onFocus={handleFocus} onFocus={handleFocus}
autoFocus={focusInput}
style={{ width: '100%' }} style={{ width: '100%' }}
role="textbox" role="textbox"
aria-label="Chat text input" aria-label="Chat text input"

View File

@ -8,6 +8,12 @@ $p-v-size: 2px;
z-index: 100; z-index: 100;
} }
// Chat messages are wrapped in <p> tags. We don't want to render
// the default margins for these initial <p> tags, so we remove them here.
p:nth-of-type(1) {
margin: initial;
}
border-left: $border-style; border-left: $border-style;
position: relative; position: relative;
font-size: var(--chat-message-text-size); font-size: var(--chat-message-text-size);

170
web/package-lock.json generated
View File

@ -43,6 +43,7 @@
"react-markdown": "8.0.7", "react-markdown": "8.0.7",
"react-virtuoso": "4.3.11", "react-virtuoso": "4.3.11",
"recoil": "0.7.7", "recoil": "0.7.7",
"sanitize-html": "^2.11.0",
"sharp": "0.32.1", "sharp": "0.32.1",
"ua-parser-js": "1.0.35", "ua-parser-js": "1.0.35",
"video.js": "^8.3.0", "video.js": "^8.3.0",
@ -77,6 +78,7 @@
"@types/prop-types": "15.7.5", "@types/prop-types": "15.7.5",
"@types/react": "18.2.14", "@types/react": "18.2.14",
"@types/react-linkify": "1.0.1", "@types/react-linkify": "1.0.1",
"@types/sanitize-html": "^2.9.0",
"@types/ua-parser-js": "0.7.36", "@types/ua-parser-js": "0.7.36",
"@types/video.js": "^7.3.50", "@types/video.js": "^7.3.50",
"@typescript-eslint/eslint-plugin": "5.60.0", "@typescript-eslint/eslint-plugin": "5.60.0",
@ -13549,6 +13551,77 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/sanitize-html": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.9.0.tgz",
"integrity": "sha512-4fP/kEcKNj2u39IzrxWYuf/FnCCwwQCpif6wwY6ROUS1EPRIfWJjGkY3HIowY1EX/VbX5e86yq8AAE7UPMgATg==",
"dev": true,
"dependencies": {
"htmlparser2": "^8.0.0"
}
},
"node_modules/@types/sanitize-html/node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"dev": true,
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/@types/sanitize-html/node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"dev": true,
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/@types/sanitize-html/node_modules/domutils": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz",
"integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==",
"dev": true,
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/@types/sanitize-html/node_modules/htmlparser2": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
"integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
"dev": true,
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.0.1",
"entities": "^4.4.0"
}
},
"node_modules/@types/scheduler": { "node_modules/@types/scheduler": {
"version": "0.16.3", "version": "0.16.3",
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.3.tgz", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.3.tgz",
@ -19997,7 +20070,6 @@
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@ -20311,7 +20383,6 @@
"version": "4.5.0", "version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"dev": true,
"engines": { "engines": {
"node": ">=0.12" "node": ">=0.12"
}, },
@ -36045,6 +36116,11 @@
"node": ">= 0.10" "node": ">= 0.10"
} }
}, },
"node_modules/parse-srcset": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
"integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q=="
},
"node_modules/parse5": { "node_modules/parse5": {
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz",
@ -39736,6 +39812,96 @@
"which": "bin/which" "which": "bin/which"
} }
}, },
"node_modules/sanitize-html": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.11.0.tgz",
"integrity": "sha512-BG68EDHRaGKqlsNjJ2xUB7gpInPA8gVx/mvjO743hZaeMCZ2DwzW7xvsqZ+KNU4QKwj86HJ3uu2liISf2qBBUA==",
"dependencies": {
"deepmerge": "^4.2.2",
"escape-string-regexp": "^4.0.0",
"htmlparser2": "^8.0.0",
"is-plain-object": "^5.0.0",
"parse-srcset": "^1.0.2",
"postcss": "^8.3.11"
}
},
"node_modules/sanitize-html/node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/sanitize-html/node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/sanitize-html/node_modules/domutils": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz",
"integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/sanitize-html/node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/sanitize-html/node_modules/htmlparser2": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
"integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.0.1",
"entities": "^4.4.0"
}
},
"node_modules/sanitize-html/node_modules/is-plain-object": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/sass": { "node_modules/sass": {
"version": "1.63.6", "version": "1.63.6",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.63.6.tgz", "resolved": "https://registry.npmjs.org/sass/-/sass-1.63.6.tgz",

View File

@ -48,6 +48,7 @@
"react-markdown": "8.0.7", "react-markdown": "8.0.7",
"react-virtuoso": "4.3.11", "react-virtuoso": "4.3.11",
"recoil": "0.7.7", "recoil": "0.7.7",
"sanitize-html": "^2.11.0",
"sharp": "0.32.1", "sharp": "0.32.1",
"ua-parser-js": "1.0.35", "ua-parser-js": "1.0.35",
"video.js": "^8.3.0", "video.js": "^8.3.0",
@ -82,6 +83,7 @@
"@types/prop-types": "15.7.5", "@types/prop-types": "15.7.5",
"@types/react": "18.2.14", "@types/react": "18.2.14",
"@types/react-linkify": "1.0.1", "@types/react-linkify": "1.0.1",
"@types/sanitize-html": "^2.9.0",
"@types/ua-parser-js": "0.7.36", "@types/ua-parser-js": "0.7.36",
"@types/video.js": "^7.3.50", "@types/video.js": "^7.3.50",
"@typescript-eslint/eslint-plugin": "5.60.0", "@typescript-eslint/eslint-plugin": "5.60.0",