feat(chat): support line breaks and pasted content. Closes #3108
This commit is contained in:
parent
bd6e263eb9
commit
a354787a9e
@ -149,7 +149,7 @@ func sanitize(raw string) string {
|
||||
// Allow breaks
|
||||
p.AllowElements("br")
|
||||
|
||||
p.AllowElementsContent("p")
|
||||
p.AllowElements("p")
|
||||
|
||||
// Allow img tags from the the local emoji directory only
|
||||
p.AllowAttrs("src").Matching(_sanitizeReSrcMatch).OnElements("img")
|
||||
|
@ -16,11 +16,11 @@ Here is an iframe<iframe src="http://yahoo.com"></iframe>
|
||||
[test link](http://owncast.online)
|
||||
<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>.
|
||||
Here is an iframe
|
||||
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</p>
|
||||
blah blah blah
|
||||
<a href="http://owncast.online" rel="nofollow noreferrer noopener" target="_blank">test link</a>
|
||||
<img class="emoji" src="/img/emoji/bananadance.gif">`
|
||||
<p><a href="http://owncast.online" rel="nofollow noreferrer noopener" target="_blank">test link</a>
|
||||
<img class="emoji" src="/img/emoji/bananadance.gif"></p>`
|
||||
|
||||
result := events.RenderAndSanitize(messageContent)
|
||||
if result != expected {
|
||||
@ -31,7 +31,7 @@ blah blah blah
|
||||
// Test to make sure we block remote images in chat messages.
|
||||
func TestBlockRemoteImages(t *testing.T) {
|
||||
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)
|
||||
|
||||
if result != expected {
|
||||
@ -42,7 +42,7 @@ func TestBlockRemoteImages(t *testing.T) {
|
||||
// Test to make sure emoji images are allowed in chat messages.
|
||||
func TestAllowEmojiImages(t *testing.T) {
|
||||
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)
|
||||
|
||||
if result != expected {
|
||||
|
@ -1,6 +1,7 @@
|
||||
{
|
||||
"extends": "stylelint-config-standard-scss",
|
||||
"rules": {
|
||||
"selector-class-pattern": null
|
||||
"selector-class-pattern": null,
|
||||
"no-descending-specificity": null
|
||||
}
|
||||
}
|
@ -1,7 +1,9 @@
|
||||
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 ContentEditable from 'react-contenteditable';
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
import classNames from 'classnames';
|
||||
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(/ /gi, ' ');
|
||||
value = value.replace(/&/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 }) => {
|
||||
const [showEmojis, setShowEmojis] = useState(false);
|
||||
const [characterCount, setCharacterCount] = useState(defaultText?.length);
|
||||
@ -132,44 +97,21 @@ export const ChatTextField: FC<ChatTextFieldProps> = ({ defaultText, enabled, fo
|
||||
};
|
||||
|
||||
const insertTextAtCursor = (textToInsert: string) => {
|
||||
const output = [
|
||||
text.current.slice(0, savedCursorLocation),
|
||||
textToInsert,
|
||||
text.current.slice(savedCursorLocation),
|
||||
].join('');
|
||||
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');
|
||||
let cursorLocation;
|
||||
if (savedCursorLocation > 0) {
|
||||
cursorLocation = savedCursorLocation;
|
||||
} else {
|
||||
cursorLocation = getCaretPosition(document.getElementById('chat-input'));
|
||||
}
|
||||
|
||||
// Insert into temp `<textarea>`, read back out.
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.innerHTML = value;
|
||||
value = textarea.innerText;
|
||||
const output = [
|
||||
text.current.slice(0, cursorLocation),
|
||||
textToInsert,
|
||||
text.current.slice(cursorLocation),
|
||||
].join('');
|
||||
|
||||
// Clean up text.
|
||||
value = convertToText(value);
|
||||
|
||||
// Insert text.
|
||||
insertTextAtCursor(value);
|
||||
text.current = output;
|
||||
forceUpdate();
|
||||
};
|
||||
|
||||
// Native emoji
|
||||
@ -184,14 +126,13 @@ export const ChatTextField: FC<ChatTextFieldProps> = ({ defaultText, enabled, fo
|
||||
};
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||
const charCount = getCharacterCount() + 1;
|
||||
|
||||
// Send the message when hitting enter.
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
// Allow native line breaks
|
||||
if (e.key === 'Enter' && e.shiftKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const charCount = getCharacterCount() + 1;
|
||||
|
||||
// Always allow backspace.
|
||||
if (e.key === 'Backspace') {
|
||||
setCharacterCount(charCount - 1);
|
||||
@ -213,11 +154,36 @@ export const ChatTextField: FC<ChatTextFieldProps> = ({ defaultText, enabled, fo
|
||||
if (charCount + 1 > characterLimit) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
// Send the message when hitting enter.
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
return;
|
||||
}
|
||||
|
||||
setCharacterCount(charCount + 1);
|
||||
};
|
||||
|
||||
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 = () => {
|
||||
@ -237,6 +203,14 @@ export const ChatTextField: FC<ChatTextFieldProps> = ({ defaultText, enabled, fo
|
||||
setSavedCursorLocation(0);
|
||||
};
|
||||
|
||||
// Focus the input when the component mounts.
|
||||
useEffect(() => {
|
||||
if (!focusInput) {
|
||||
return;
|
||||
}
|
||||
document.getElementById('chat-input-content-editable').focus();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div id="chat-input" className={styles.root}>
|
||||
<div
|
||||
@ -260,11 +234,9 @@ export const ChatTextField: FC<ChatTextFieldProps> = ({ defaultText, enabled, fo
|
||||
placeholder={enabled ? 'Send a message to chat' : 'Chat is disabled'}
|
||||
disabled={!enabled}
|
||||
onKeyDown={onKeyDown}
|
||||
onPaste={convertOnPaste}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
autoFocus={focusInput}
|
||||
style={{ width: '100%' }}
|
||||
role="textbox"
|
||||
aria-label="Chat text input"
|
||||
|
@ -8,6 +8,12 @@ $p-v-size: 2px;
|
||||
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;
|
||||
position: relative;
|
||||
font-size: var(--chat-message-text-size);
|
||||
|
170
web/package-lock.json
generated
170
web/package-lock.json
generated
@ -43,6 +43,7 @@
|
||||
"react-markdown": "8.0.7",
|
||||
"react-virtuoso": "4.3.11",
|
||||
"recoil": "0.7.7",
|
||||
"sanitize-html": "^2.11.0",
|
||||
"sharp": "0.32.1",
|
||||
"ua-parser-js": "1.0.35",
|
||||
"video.js": "^8.3.0",
|
||||
@ -77,6 +78,7 @@
|
||||
"@types/prop-types": "15.7.5",
|
||||
"@types/react": "18.2.14",
|
||||
"@types/react-linkify": "1.0.1",
|
||||
"@types/sanitize-html": "^2.9.0",
|
||||
"@types/ua-parser-js": "0.7.36",
|
||||
"@types/video.js": "^7.3.50",
|
||||
"@typescript-eslint/eslint-plugin": "5.60.0",
|
||||
@ -13549,6 +13551,77 @@
|
||||
"@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": {
|
||||
"version": "0.16.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.3.tgz",
|
||||
@ -19997,7 +20070,6 @@
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@ -20311,7 +20383,6 @@
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
@ -36045,6 +36116,11 @@
|
||||
"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": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz",
|
||||
@ -39736,6 +39812,96 @@
|
||||
"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": {
|
||||
"version": "1.63.6",
|
||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.63.6.tgz",
|
||||
|
@ -48,6 +48,7 @@
|
||||
"react-markdown": "8.0.7",
|
||||
"react-virtuoso": "4.3.11",
|
||||
"recoil": "0.7.7",
|
||||
"sanitize-html": "^2.11.0",
|
||||
"sharp": "0.32.1",
|
||||
"ua-parser-js": "1.0.35",
|
||||
"video.js": "^8.3.0",
|
||||
@ -82,6 +83,7 @@
|
||||
"@types/prop-types": "15.7.5",
|
||||
"@types/react": "18.2.14",
|
||||
"@types/react-linkify": "1.0.1",
|
||||
"@types/sanitize-html": "^2.9.0",
|
||||
"@types/ua-parser-js": "0.7.36",
|
||||
"@types/video.js": "^7.3.50",
|
||||
"@typescript-eslint/eslint-plugin": "5.60.0",
|
||||
|
Loading…
x
Reference in New Issue
Block a user