Chat wire protocol (#3125)
* core: remove file extension from emoji name * web: transform emotes to labels when sending * chat: replace br with line break * core: implement emoji cache * chat: send shortcodes for custom emoji * chat: correct esling errors * core: move emoji injection into dedicated function * emoji: integrate emoji into markdown renderer, fix formatting * chat protocol: correct golangci-lint findings * chat field: specify that the contentEditable is an HTMLElement * admin: mention that emoji should have unique names * Prettified Code! * regenerate pack-lock * chat: correct the emphasis tag, provide fallback for other elements --------- Co-authored-by: jprjr <jprjr@users.noreply.github.com>
This commit is contained in:
parent
e9a4899686
commit
46ca5223f9
@ -4,15 +4,22 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"text/template"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/microcosm-cc/bluemonday"
|
"github.com/microcosm-cc/bluemonday"
|
||||||
"github.com/teris-io/shortid"
|
"github.com/teris-io/shortid"
|
||||||
"github.com/yuin/goldmark"
|
"github.com/yuin/goldmark"
|
||||||
|
emoji "github.com/yuin/goldmark-emoji"
|
||||||
|
emojiAst "github.com/yuin/goldmark-emoji/ast"
|
||||||
|
emojiDef "github.com/yuin/goldmark-emoji/definition"
|
||||||
"github.com/yuin/goldmark/extension"
|
"github.com/yuin/goldmark/extension"
|
||||||
"github.com/yuin/goldmark/renderer/html"
|
"github.com/yuin/goldmark/renderer/html"
|
||||||
|
"github.com/yuin/goldmark/util"
|
||||||
"mvdan.cc/xurls"
|
"mvdan.cc/xurls"
|
||||||
|
|
||||||
|
"github.com/owncast/owncast/core/data"
|
||||||
"github.com/owncast/owncast/core/user"
|
"github.com/owncast/owncast/core/user"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
@ -66,6 +73,105 @@ func (e *UserMessageEvent) SetDefaults() {
|
|||||||
e.RenderAndSanitizeMessageBody()
|
e.RenderAndSanitizeMessageBody()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// implements the emojiDef.Emojis interface but uses case-insensitive search.
|
||||||
|
// the .children field isn't currently used, but could be used in a future
|
||||||
|
// implementation of say, emoji packs where a child represents a pack.
|
||||||
|
type emojis struct {
|
||||||
|
list []emojiDef.Emoji
|
||||||
|
names map[string]*emojiDef.Emoji
|
||||||
|
children []emojiDef.Emojis
|
||||||
|
}
|
||||||
|
|
||||||
|
// return a new Emojis set.
|
||||||
|
func newEmojis(emotes ...emojiDef.Emoji) emojiDef.Emojis {
|
||||||
|
self := &emojis{
|
||||||
|
list: emotes,
|
||||||
|
names: map[string]*emojiDef.Emoji{},
|
||||||
|
children: []emojiDef.Emojis{},
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range self.list {
|
||||||
|
emoji := &self.list[i]
|
||||||
|
for _, s := range emoji.ShortNames {
|
||||||
|
self.names[s] = emoji
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
|
||||||
|
func (self *emojis) Get(shortName string) (*emojiDef.Emoji, bool) {
|
||||||
|
v, ok := self.names[strings.ToLower(shortName)]
|
||||||
|
if ok {
|
||||||
|
return v, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, child := range self.children {
|
||||||
|
v, ok := child.Get(shortName)
|
||||||
|
if ok {
|
||||||
|
return v, ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (self *emojis) Add(emotes emojiDef.Emojis) {
|
||||||
|
self.children = append(self.children, emotes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (self *emojis) Clone() emojiDef.Emojis {
|
||||||
|
clone := &emojis{
|
||||||
|
list: self.list,
|
||||||
|
names: self.names,
|
||||||
|
children: make([]emojiDef.Emojis, len(self.children)),
|
||||||
|
}
|
||||||
|
|
||||||
|
copy(clone.children, self.children)
|
||||||
|
|
||||||
|
return clone
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
emojiMu sync.Mutex
|
||||||
|
emojiDefs = newEmojis()
|
||||||
|
emojiHTML = make(map[string]string)
|
||||||
|
emojiModTime time.Time
|
||||||
|
emojiHTMLFormat = `<img src="{{ .URL }}" class="emoji" alt=":{{ .Name }}:" title=":{{ .Name }}:">`
|
||||||
|
emojiHTMLTemplate = template.Must(template.New("emojiHTML").Parse(emojiHTMLFormat))
|
||||||
|
)
|
||||||
|
|
||||||
|
func loadEmoji() {
|
||||||
|
modTime, err := data.UpdateEmojiList(false)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if modTime.After(emojiModTime) {
|
||||||
|
emojiMu.Lock()
|
||||||
|
defer emojiMu.Unlock()
|
||||||
|
|
||||||
|
emojiHTML = make(map[string]string)
|
||||||
|
|
||||||
|
emojiList := data.GetEmojiList()
|
||||||
|
emojiArr := make([]emojiDef.Emoji, 0)
|
||||||
|
|
||||||
|
for i := 0; i < len(emojiList); i++ {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err := emojiHTMLTemplate.Execute(&buf, emojiList[i])
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emojiHTML[strings.ToLower(emojiList[i].Name)] = buf.String()
|
||||||
|
|
||||||
|
emoji := emojiDef.NewEmoji(emojiList[i].Name, nil, strings.ToLower(emojiList[i].Name))
|
||||||
|
emojiArr = append(emojiArr, emoji)
|
||||||
|
}
|
||||||
|
|
||||||
|
emojiDefs = newEmojis(emojiArr...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// RenderAndSanitizeMessageBody will turn markdown into HTML, sanitize raw user-supplied HTML and standardize
|
// RenderAndSanitizeMessageBody will turn markdown into HTML, sanitize raw user-supplied HTML and standardize
|
||||||
// the message into something safe and renderable for clients.
|
// the message into something safe and renderable for clients.
|
||||||
func (m *MessageEvent) RenderAndSanitizeMessageBody() {
|
func (m *MessageEvent) RenderAndSanitizeMessageBody() {
|
||||||
@ -98,6 +204,11 @@ func RenderAndSanitize(raw string) string {
|
|||||||
|
|
||||||
// RenderMarkdown will return HTML rendered from the string body of a chat message.
|
// RenderMarkdown will return HTML rendered from the string body of a chat message.
|
||||||
func RenderMarkdown(raw string) string {
|
func RenderMarkdown(raw string) string {
|
||||||
|
loadEmoji()
|
||||||
|
|
||||||
|
emojiMu.Lock()
|
||||||
|
defer emojiMu.Unlock()
|
||||||
|
|
||||||
markdown := goldmark.New(
|
markdown := goldmark.New(
|
||||||
goldmark.WithRendererOptions(
|
goldmark.WithRendererOptions(
|
||||||
html.WithUnsafe(),
|
html.WithUnsafe(),
|
||||||
@ -112,6 +223,16 @@ func RenderMarkdown(raw string) string {
|
|||||||
xurls.Strict,
|
xurls.Strict,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
emoji.New(
|
||||||
|
emoji.WithEmojis(
|
||||||
|
emojiDefs,
|
||||||
|
),
|
||||||
|
emoji.WithRenderingMethod(emoji.Func),
|
||||||
|
emoji.WithRendererFunc(func(w util.BufWriter, source []byte, n *emojiAst.Emoji, config *emoji.RendererConfig) {
|
||||||
|
baseName := n.Value.ShortNames[0]
|
||||||
|
_, _ = w.WriteString(emojiHTML[baseName])
|
||||||
|
}),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -6,6 +6,8 @@ import (
|
|||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/owncast/owncast/config"
|
"github.com/owncast/owncast/config"
|
||||||
"github.com/owncast/owncast/models"
|
"github.com/owncast/owncast/models"
|
||||||
@ -15,29 +17,75 @@ import (
|
|||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var emojiCacheMu sync.Mutex
|
||||||
|
var emojiCacheData = make([]models.CustomEmoji, 0)
|
||||||
|
var emojiCacheModTime time.Time
|
||||||
|
|
||||||
|
// UpdateEmojiList will update the cache (if required) and
|
||||||
|
// return the modifiation time.
|
||||||
|
func UpdateEmojiList(force bool) (time.Time, error) {
|
||||||
|
var modTime time.Time
|
||||||
|
|
||||||
|
emojiPathInfo, err := os.Stat(config.CustomEmojiPath)
|
||||||
|
if err != nil {
|
||||||
|
return modTime, err
|
||||||
|
}
|
||||||
|
|
||||||
|
modTime = emojiPathInfo.ModTime()
|
||||||
|
|
||||||
|
if modTime.After(emojiCacheModTime) || force {
|
||||||
|
emojiCacheMu.Lock()
|
||||||
|
defer emojiCacheMu.Unlock()
|
||||||
|
|
||||||
|
// double-check that another thread didn't update this while waiting.
|
||||||
|
if modTime.After(emojiCacheModTime) || force {
|
||||||
|
emojiCacheModTime = modTime
|
||||||
|
if force {
|
||||||
|
emojiCacheModTime = time.Now()
|
||||||
|
}
|
||||||
|
emojiFS := os.DirFS(config.CustomEmojiPath)
|
||||||
|
|
||||||
|
emojiCacheData = make([]models.CustomEmoji, 0)
|
||||||
|
|
||||||
|
walkFunction := func(path string, d os.DirEntry, err error) error {
|
||||||
|
if d.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
emojiPath := filepath.Join(config.EmojiDir, path)
|
||||||
|
fileName := d.Name()
|
||||||
|
fileBase := fileName[:len(fileName)-len(filepath.Ext(fileName))]
|
||||||
|
singleEmoji := models.CustomEmoji{Name: fileBase, URL: emojiPath}
|
||||||
|
emojiCacheData = append(emojiCacheData, singleEmoji)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := fs.WalkDir(emojiFS, ".", walkFunction); err != nil {
|
||||||
|
log.Errorln("unable to fetch emojis: " + err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return modTime, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetEmojiList returns a list of custom emoji from the emoji directory.
|
// GetEmojiList returns a list of custom emoji from the emoji directory.
|
||||||
func GetEmojiList() []models.CustomEmoji {
|
func GetEmojiList() []models.CustomEmoji {
|
||||||
emojiFS := os.DirFS(config.CustomEmojiPath)
|
_, err := UpdateEmojiList(false)
|
||||||
|
if err != nil {
|
||||||
emojiResponse := make([]models.CustomEmoji, 0)
|
|
||||||
|
|
||||||
walkFunction := func(path string, d os.DirEntry, err error) error {
|
|
||||||
if d.IsDir() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
emojiPath := filepath.Join(config.EmojiDir, path)
|
|
||||||
singleEmoji := models.CustomEmoji{Name: d.Name(), URL: emojiPath}
|
|
||||||
emojiResponse = append(emojiResponse, singleEmoji)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := fs.WalkDir(emojiFS, ".", walkFunction); err != nil {
|
// Lock to make sure this doesn't get updated in the middle of reading
|
||||||
log.Errorln("unable to fetch emojis: " + err.Error())
|
emojiCacheMu.Lock()
|
||||||
return emojiResponse
|
defer emojiCacheMu.Unlock()
|
||||||
}
|
|
||||||
|
|
||||||
return emojiResponse
|
// return a copy of cache data, ensures underlying slice isn't affected
|
||||||
|
// by future update
|
||||||
|
emojiData := make([]models.CustomEmoji, len(emojiCacheData))
|
||||||
|
copy(emojiData, emojiCacheData)
|
||||||
|
|
||||||
|
return emojiData
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetupEmojiDirectory sets up the custom emoji directory by copying all built-in
|
// SetupEmojiDirectory sets up the custom emoji directory by copying all built-in
|
||||||
|
1
go.mod
1
go.mod
@ -69,6 +69,7 @@ require (
|
|||||||
github.com/jmespath/go-jmespath v0.4.0 // indirect
|
github.com/jmespath/go-jmespath v0.4.0 // indirect
|
||||||
github.com/oschwald/maxminddb-golang v1.11.0 // indirect
|
github.com/oschwald/maxminddb-golang v1.11.0 // indirect
|
||||||
github.com/shoenig/go-m1cpu v0.1.6 // indirect
|
github.com/shoenig/go-m1cpu v0.1.6 // indirect
|
||||||
|
github.com/yuin/goldmark-emoji v1.0.2 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
3
go.sum
3
go.sum
@ -160,9 +160,12 @@ github.com/tklauser/numcpus v0.6.0 h1:kebhY2Qt+3U6RNK7UqpYNA+tJ23IBEGKkB7JQBfDYm
|
|||||||
github.com/tklauser/numcpus v0.6.0/go.mod h1:FEZLMke0lhOUG6w2JadTzp0a+Nl8PF/GFkQ5UVIcaL4=
|
github.com/tklauser/numcpus v0.6.0/go.mod h1:FEZLMke0lhOUG6w2JadTzp0a+Nl8PF/GFkQ5UVIcaL4=
|
||||||
github.com/valyala/gozstd v1.11.0 h1:VV6qQFt+4sBBj9OJ7eKVvsFAMy59Urcs9Lgd+o5FOw0=
|
github.com/valyala/gozstd v1.11.0 h1:VV6qQFt+4sBBj9OJ7eKVvsFAMy59Urcs9Lgd+o5FOw0=
|
||||||
github.com/valyala/gozstd v1.11.0/go.mod h1:y5Ew47GLlP37EkTB+B4s7r6A5rdaeB7ftbl9zoYiIPQ=
|
github.com/valyala/gozstd v1.11.0/go.mod h1:y5Ew47GLlP37EkTB+B4s7r6A5rdaeB7ftbl9zoYiIPQ=
|
||||||
|
github.com/yuin/goldmark v1.3.7/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
github.com/yuin/goldmark v1.5.4 h1:2uY/xC0roWy8IBEGLgB1ywIoEJFGmRrX21YQcvGZzjU=
|
github.com/yuin/goldmark v1.5.4 h1:2uY/xC0roWy8IBEGLgB1ywIoEJFGmRrX21YQcvGZzjU=
|
||||||
github.com/yuin/goldmark v1.5.4/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.5.4/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
github.com/yuin/goldmark-emoji v1.0.2 h1:c/RgTShNgHTtc6xdz2KKI74jJr6rWi7FPgnP9GAsO5s=
|
||||||
|
github.com/yuin/goldmark-emoji v1.0.2/go.mod h1:RhP/RWpexdp+KHs7ghKnifRoIs/Bq4nDS7tRbCkOwKY=
|
||||||
github.com/yusufpapurcu/wmi v1.2.2 h1:KBNDSne4vP5mbSWnJbO+51IMOXJB67QiYCSBrubbPRg=
|
github.com/yusufpapurcu/wmi v1.2.2 h1:KBNDSne4vP5mbSWnJbO+51IMOXJB67QiYCSBrubbPRg=
|
||||||
github.com/yusufpapurcu/wmi v1.2.2/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
github.com/yusufpapurcu/wmi v1.2.2/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||||
github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw=
|
github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw=
|
||||||
|
@ -3,6 +3,7 @@ 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 sanitizeHtml from 'sanitize-html';
|
||||||
|
import GraphemeSplitter from 'grapheme-splitter';
|
||||||
|
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
@ -32,32 +33,118 @@ export type ChatTextFieldProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const characterLimit = 300;
|
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<ChatTextFieldProps> = ({ defaultText, enabled, focusInput }) => {
|
export const ChatTextField: FC<ChatTextFieldProps> = ({ defaultText, enabled, focusInput }) => {
|
||||||
const [characterCount, setCharacterCount] = useState(defaultText?.length);
|
const [characterCount, setCharacterCount] = useState(defaultText?.length);
|
||||||
const websocketService = useRecoilValue<WebsocketService>(websocketServiceAtom);
|
const websocketService = useRecoilValue<WebsocketService>(websocketServiceAtom);
|
||||||
const text = useRef(defaultText || '');
|
const text = useRef(defaultText || '');
|
||||||
|
const contentEditable = React.createRef<HTMLElement>();
|
||||||
const [customEmoji, setCustomEmoji] = useState([]);
|
const [customEmoji, setCustomEmoji] = useState([]);
|
||||||
|
|
||||||
// This is a bit of a hack to force the component to re-render when the text changes.
|
// 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.
|
// By default when updating a ref the component doesn't re-render.
|
||||||
const [, forceUpdate] = useReducer(x => x + 1, 0);
|
const [, forceUpdate] = useReducer(x => x + 1, 0);
|
||||||
|
|
||||||
const getCharacterCount = () => text.current.length;
|
const getCharacterCount = () => {
|
||||||
|
const message = getTextContent(contentEditable.current);
|
||||||
|
return graphemeSplitter.countGraphemes(message);
|
||||||
|
};
|
||||||
|
|
||||||
const sendMessage = () => {
|
const sendMessage = () => {
|
||||||
const count = getCharacterCount();
|
|
||||||
|
|
||||||
if (!websocketService) {
|
if (!websocketService) {
|
||||||
console.log('websocketService is not defined');
|
console.log('websocketService is not defined');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const message = getTextContent(contentEditable.current);
|
||||||
|
const count = graphemeSplitter.countGraphemes(message);
|
||||||
if (count === 0 || count > characterLimit) return;
|
if (count === 0 || count > characterLimit) return;
|
||||||
|
|
||||||
let message = text.current;
|
|
||||||
// Strip the opening and closing <p> tags.
|
|
||||||
message = message.replace(/^<p>|<\/p>$/g, '');
|
|
||||||
websocketService.send({ type: MessageType.CHAT, body: message });
|
websocketService.send({ type: MessageType.CHAT, body: message });
|
||||||
|
|
||||||
// Clear the input.
|
// Clear the input.
|
||||||
@ -70,18 +157,19 @@ export const ChatTextField: FC<ChatTextFieldProps> = ({ defaultText, enabled, fo
|
|||||||
const output = text.current + textToInsert;
|
const output = text.current + textToInsert;
|
||||||
text.current = output;
|
text.current = output;
|
||||||
|
|
||||||
setCharacterCount(getCharacterCount());
|
|
||||||
forceUpdate();
|
forceUpdate();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Native emoji
|
// Native emoji
|
||||||
const onEmojiSelect = (emoji: string) => {
|
const onEmojiSelect = (emoji: string) => {
|
||||||
|
setCharacterCount(getCharacterCount() + 1);
|
||||||
insertTextAtEnd(emoji);
|
insertTextAtEnd(emoji);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Custom emoji images
|
// Custom emoji images
|
||||||
const onCustomEmojiSelect = (name: string, emoji: string) => {
|
const onCustomEmojiSelect = (name: string, emoji: string) => {
|
||||||
const html = `<img src="${emoji}" alt="${name}" title="${name}" class="emoji" />`;
|
const html = `<img src="${emoji}" alt=":${name}:" title=":${name}:" class="emoji" />`;
|
||||||
|
setCharacterCount(getCharacterCount() + name.length + 2);
|
||||||
insertTextAtEnd(html);
|
insertTextAtEnd(html);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -161,6 +249,7 @@ export const ChatTextField: FC<ChatTextFieldProps> = ({ defaultText, enabled, fo
|
|||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
role="textbox"
|
role="textbox"
|
||||||
aria-label="Chat text input"
|
aria-label="Chat text input"
|
||||||
|
innerRef={contentEditable}
|
||||||
/>
|
/>
|
||||||
{enabled && (
|
{enabled && (
|
||||||
<div style={{ display: 'flex', paddingLeft: '5px' }}>
|
<div style={{ display: 'flex', paddingLeft: '5px' }}>
|
||||||
|
37204
web/package-lock.json
generated
37204
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -30,6 +30,7 @@
|
|||||||
"chart.js": "^4.2.0",
|
"chart.js": "^4.2.0",
|
||||||
"classnames": "2.3.2",
|
"classnames": "2.3.2",
|
||||||
"date-fns": "^2.29.3",
|
"date-fns": "^2.29.3",
|
||||||
|
"grapheme-splitter": "^1.0.4",
|
||||||
"interweave": "^13.0.0",
|
"interweave": "^13.0.0",
|
||||||
"interweave-autolink": "^5.1.0",
|
"interweave-autolink": "^5.1.0",
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
|
@ -136,7 +136,8 @@ const Emoji = () => {
|
|||||||
<Title>Emojis</Title>
|
<Title>Emojis</Title>
|
||||||
<Paragraph>
|
<Paragraph>
|
||||||
Here you can upload new custom emojis for usage in the chat. When uploading a new emoji, the
|
Here you can upload new custom emojis for usage in the chat. When uploading a new emoji, the
|
||||||
filename will be used as emoji name.
|
filename without extension will be used as emoji name. Additionally, emoji names are
|
||||||
|
case-insensitive. For best results, ensure all emoji have unique names.
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
<br />
|
<br />
|
||||||
<Upload
|
<Upload
|
||||||
|
Loading…
x
Reference in New Issue
Block a user