mirror of
https://github.com/owncast/owncast.git
synced 2024-11-22 12:49:37 +03:00
simpler chatbox (#3146)
This commit is contained in:
parent
aeed7a678d
commit
c132d82645
4 changed files with 115 additions and 51 deletions
|
@ -1,12 +1,12 @@
|
|||
import { Popover } from 'antd';
|
||||
import React, { FC, useEffect, useReducer, useRef, useState } from 'react';
|
||||
import React, { FC, useEffect, useState } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import ContentEditable from 'react-contenteditable';
|
||||
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';
|
||||
|
@ -122,16 +122,15 @@ const getTextContent = node => {
|
|||
export const ChatTextField: FC<ChatTextFieldProps> = ({ defaultText, enabled, focusInput }) => {
|
||||
const [characterCount, setCharacterCount] = useState(defaultText?.length);
|
||||
const websocketService = useRecoilValue<WebsocketService>(websocketServiceAtom);
|
||||
const text = useRef(defaultText || '');
|
||||
const contentEditable = React.createRef<HTMLElement>();
|
||||
const [contentEditable, setContentEditable] = useState(null);
|
||||
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 onRootRef = el => {
|
||||
setContentEditable(el);
|
||||
};
|
||||
|
||||
const getCharacterCount = () => {
|
||||
const message = getTextContent(contentEditable.current);
|
||||
const message = getTextContent(contentEditable);
|
||||
return graphemeSplitter.countGraphemes(message);
|
||||
};
|
||||
|
||||
|
@ -141,35 +140,26 @@ export const ChatTextField: FC<ChatTextFieldProps> = ({ defaultText, enabled, fo
|
|||
return;
|
||||
}
|
||||
|
||||
const message = getTextContent(contentEditable.current);
|
||||
const message = getTextContent(contentEditable);
|
||||
const count = graphemeSplitter.countGraphemes(message);
|
||||
if (count === 0 || count > characterLimit) return;
|
||||
|
||||
websocketService.send({ type: MessageType.CHAT, body: message });
|
||||
|
||||
// Clear the input.
|
||||
text.current = '';
|
||||
setCharacterCount(0);
|
||||
forceUpdate();
|
||||
contentEditable.innerHTML = '';
|
||||
};
|
||||
|
||||
const insertTextAtEnd = (textToInsert: string) => {
|
||||
const output = text.current + textToInsert;
|
||||
text.current = output;
|
||||
|
||||
forceUpdate();
|
||||
contentEditable.innerHTML += textToInsert;
|
||||
};
|
||||
|
||||
// Native emoji
|
||||
const onEmojiSelect = (emoji: string) => {
|
||||
setCharacterCount(getCharacterCount() + 1);
|
||||
insertTextAtEnd(emoji);
|
||||
};
|
||||
|
||||
// Custom emoji images
|
||||
const onCustomEmojiSelect = (name: string, emoji: string) => {
|
||||
const html = `<img src="${emoji}" alt=":${name}:" title=":${name}:" class="emoji" />`;
|
||||
setCharacterCount(getCharacterCount() + name.length + 2);
|
||||
insertTextAtEnd(html);
|
||||
};
|
||||
|
||||
|
@ -180,8 +170,27 @@ export const ChatTextField: FC<ChatTextFieldProps> = ({ defaultText, enabled, fo
|
|||
}
|
||||
};
|
||||
|
||||
const handleChange = evt => {
|
||||
const sanitized = sanitizeHtml(evt.target.value, {
|
||||
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'],
|
||||
|
@ -196,9 +205,22 @@ export const ChatTextField: FC<ChatTextFieldProps> = ({ defaultText, enabled, fo
|
|||
},
|
||||
});
|
||||
|
||||
if (text.current !== sanitized) text.current = sanitized;
|
||||
// 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);
|
||||
};
|
||||
|
||||
setCharacterCount(getCharacterCount());
|
||||
const handleChange = () => {
|
||||
const count = getCharacterCount();
|
||||
setCharacterCount(count);
|
||||
|
||||
if (count === 0 && contentEditable.children.length === 1) {
|
||||
/* if we have a single <br> 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.
|
||||
|
@ -241,15 +263,16 @@ export const ChatTextField: FC<ChatTextFieldProps> = ({ defaultText, enabled, fo
|
|||
>
|
||||
<ContentEditable
|
||||
id="chat-input-content-editable"
|
||||
html={text.current}
|
||||
html={defaultText || ''}
|
||||
placeholder={enabled ? 'Send a message to chat' : 'Chat is disabled'}
|
||||
disabled={!enabled}
|
||||
onKeyDown={onKeyDown}
|
||||
onChange={handleChange}
|
||||
style={{ width: '100%' }}
|
||||
onContentChange={handleChange}
|
||||
onPaste={onPaste}
|
||||
onRootRef={onRootRef}
|
||||
style={{ whiteSpace: 'pre-wrap', width: '100%' }}
|
||||
role="textbox"
|
||||
aria-label="Chat text input"
|
||||
innerRef={contentEditable}
|
||||
/>
|
||||
{enabled && (
|
||||
<div style={{ display: 'flex', paddingLeft: '5px' }}>
|
||||
|
|
64
web/components/chat/ChatTextField/ContentEditable.tsx
Normal file
64
web/components/chat/ChatTextField/ContentEditable.tsx
Normal file
|
@ -0,0 +1,64 @@
|
|||
import * as React from 'react';
|
||||
|
||||
export interface ContentEditableProps extends React.HTMLAttributes<HTMLElement> {
|
||||
onRootRef?: Function;
|
||||
onContentChange?: Function;
|
||||
tagName?: string;
|
||||
html: string;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export default class ContentEditable extends React.Component<ContentEditableProps> {
|
||||
private root: HTMLElement;
|
||||
|
||||
private mutationObserver: MutationObserver;
|
||||
|
||||
private innerHTMLBuffer: string;
|
||||
|
||||
public componentDidMount() {
|
||||
this.mutationObserver = new MutationObserver(this.onContentChange);
|
||||
this.mutationObserver.observe(this.root, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true,
|
||||
});
|
||||
}
|
||||
|
||||
private onContentChange = (mutations: MutationRecord[]) => {
|
||||
mutations.forEach(() => {
|
||||
const { innerHTML } = this.root;
|
||||
|
||||
if (this.innerHTMLBuffer === undefined || this.innerHTMLBuffer !== innerHTML) {
|
||||
this.innerHTMLBuffer = innerHTML;
|
||||
|
||||
if (this.props.onContentChange) {
|
||||
this.props.onContentChange({
|
||||
target: {
|
||||
value: innerHTML,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
private onRootRef = (elt: HTMLElement) => {
|
||||
this.root = elt;
|
||||
if (this.props.onRootRef) {
|
||||
this.props.onRootRef(this.root);
|
||||
}
|
||||
};
|
||||
|
||||
public render() {
|
||||
const { tagName, html, ...newProps } = this.props;
|
||||
|
||||
delete newProps.onRootRef;
|
||||
|
||||
return React.createElement(tagName || 'div', {
|
||||
...newProps,
|
||||
ref: this.onRootRef,
|
||||
contentEditable: !this.props.disabled,
|
||||
dangerouslySetInnerHTML: { __html: html },
|
||||
});
|
||||
}
|
||||
}
|
22
web/package-lock.json
generated
22
web/package-lock.json
generated
|
@ -36,7 +36,6 @@
|
|||
"postcss-flexbugs-fixes": "5.0.2",
|
||||
"react": "18.2.0",
|
||||
"react-chartjs-2": "^5.2.0",
|
||||
"react-contenteditable": "^3.3.7",
|
||||
"react-dom": "18.2.0",
|
||||
"react-error-boundary": "^4.0.0",
|
||||
"react-hotkeys-hook": "4.4.1",
|
||||
|
@ -38218,18 +38217,6 @@
|
|||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-contenteditable": {
|
||||
"version": "3.3.7",
|
||||
"resolved": "https://registry.npmjs.org/react-contenteditable/-/react-contenteditable-3.3.7.tgz",
|
||||
"integrity": "sha512-GA9NbC0DkDdpN3iGvib/OMHWTJzDX2cfkgy5Tt98JJAbA3kLnyrNbBIpsSpPpq7T8d3scD39DHP+j8mAM7BIfQ==",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"prop-types": "^15.7.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.3"
|
||||
}
|
||||
},
|
||||
"node_modules/react-docgen": {
|
||||
"version": "5.4.3",
|
||||
"resolved": "https://registry.npmjs.org/react-docgen/-/react-docgen-5.4.3.tgz",
|
||||
|
@ -74380,15 +74367,6 @@
|
|||
"integrity": "sha512-98iN5aguJyVSxp5U3CblRLH67J8gkfyGNbiK3c+l1QI/G4irHMPQw44aEPmjVag+YKTyQ260NcF82GTQ3bdscA==",
|
||||
"requires": {}
|
||||
},
|
||||
"react-contenteditable": {
|
||||
"version": "3.3.7",
|
||||
"resolved": "https://registry.npmjs.org/react-contenteditable/-/react-contenteditable-3.3.7.tgz",
|
||||
"integrity": "sha512-GA9NbC0DkDdpN3iGvib/OMHWTJzDX2cfkgy5Tt98JJAbA3kLnyrNbBIpsSpPpq7T8d3scD39DHP+j8mAM7BIfQ==",
|
||||
"requires": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"prop-types": "^15.7.1"
|
||||
}
|
||||
},
|
||||
"react-docgen": {
|
||||
"version": "5.4.3",
|
||||
"resolved": "https://registry.npmjs.org/react-docgen/-/react-docgen-5.4.3.tgz",
|
||||
|
|
|
@ -41,7 +41,6 @@
|
|||
"postcss-flexbugs-fixes": "5.0.2",
|
||||
"react": "18.2.0",
|
||||
"react-chartjs-2": "^5.2.0",
|
||||
"react-contenteditable": "^3.3.7",
|
||||
"react-dom": "18.2.0",
|
||||
"react-error-boundary": "^4.0.0",
|
||||
"react-hotkeys-hook": "4.4.1",
|
||||
|
|
Loading…
Reference in a new issue