diff --git a/web/components/chat/ChatContainer/ChatContainer.tsx b/web/components/chat/ChatContainer/ChatContainer.tsx index ea6460b3f..cba0a7afb 100644 --- a/web/components/chat/ChatContainer/ChatContainer.tsx +++ b/web/components/chat/ChatContainer/ChatContainer.tsx @@ -30,6 +30,7 @@ export type ChatContainerProps = { height?: string; chatAvailable: boolean; focusInput?: boolean; + knownChatUserDisplayNames?: string[]; }; function shouldCollapseMessages( @@ -92,6 +93,7 @@ export const ChatContainer: FC = ({ isModerator, showInput, height, + knownChatUserDisplayNames, chatAvailable: chatEnabled, focusInput = true, }) => { @@ -285,7 +287,11 @@ export const ChatContainer: FC = ({ {MessagesTable} {showInput && (
- +
)} diff --git a/web/components/chat/ChatTextField/ChatTextField.module.scss b/web/components/chat/ChatTextField/ChatTextField.module.scss index 45ee52886..e1c6921fc 100644 --- a/web/components/chat/ChatTextField/ChatTextField.module.scss +++ b/web/components/chat/ChatTextField/ChatTextField.module.scss @@ -35,14 +35,14 @@ div[role='textbox'] { font-size: 13px; - font-weight: 400; + font-weight: 400; padding: 0.3rem; background-color: inherit; border-color: var(--theme-color-components-form-field-border); box-shadow: 0; transition: box-shadow 50ms ease-in-out; &:focus { - outline: 1px solid var(--color-owncast-gray-500) !important; + outline: 1px solid var(--theme-color-components-form-field-background) !important; } & > p { margin: 0px; @@ -54,7 +54,7 @@ border: none; background: none; cursor: pointer; - padding: 0 .25rem; + padding: 0 0.25rem; } .sendButton { @@ -63,3 +63,32 @@ cursor: pointer; padding: 0 1rem; } + +.userMention { + display: inline; + font-size: 0.7rem; + padding: 3px; + margin-right: 2px; + margin-left: 2px; + border-radius: 5px; + color: var(--theme-color-components-form-field-text); + border: 0.05rem solid var(--theme-color-components-form-field-border); + background-color: var(--theme-color-components-form-field-background); +} + +.autocompleteSelectMenu { + width: 120px; + right: '5px'; + bottom: '5px'; + z-index: 99999; + + :global { + .ant-select-selection-item { + display: none; + } + + .ant-select-dropdown { + display: block; + } + } +} diff --git a/web/components/chat/ChatTextField/ChatTextField.tsx b/web/components/chat/ChatTextField/ChatTextField.tsx index ae78a7821..8a5b6560c 100644 --- a/web/components/chat/ChatTextField/ChatTextField.tsx +++ b/web/components/chat/ChatTextField/ChatTextField.tsx @@ -1,5 +1,6 @@ -import { Popover } from 'antd'; -import React, { FC, useMemo, useState } from 'react'; +/* eslint-disable jsx-a11y/no-static-element-interactions */ +import { Popover, Select } from 'antd'; +import React, { FC, useCallback, useMemo, useRef, useState } from 'react'; import { useRecoilValue } from 'recoil'; import { Transforms, createEditor, BaseEditor, Text, Descendant, Editor } from 'slate'; import { @@ -32,7 +33,10 @@ const SmileOutlined = dynamic(() => import('@ant-design/icons/SmileOutlined'), { ssr: false, }); -type CustomElement = { type: 'paragraph' | 'span'; children: CustomText[] } | ImageNode; +type CustomElement = + | { type: 'paragraph' | 'span'; children: CustomText[] } + | ImageNode + | MentionElement; type CustomText = { text: string }; type EmptyText = { @@ -47,6 +51,12 @@ type ImageNode = { children: EmptyText[]; }; +type MentionElement = { + type: 'mention'; + name: string; + children: CustomText[]; +}; + declare module 'slate' { interface CustomTypes { Editor: BaseEditor & ReactEditor; @@ -78,6 +88,17 @@ const Image = p => { ); }; +const Mention = p => { + const { attributes, element, children } = p; + + return ( + + @{element.name}  + {children} + + ); +}; + const withImages = editor => { const { isVoid } = editor; @@ -89,6 +110,17 @@ const withImages = editor => { return editor; }; +const withMentions = editor => { + const { isInline, isVoid } = editor; + + // eslint-disable-next-line no-param-reassign + editor.isInline = element => (element.type === 'mention' ? true : isInline(element)); + // eslint-disable-next-line no-param-reassign + editor.isVoid = element => (element.type === 'mention' ? true : isVoid(element)); + + return editor; +}; + const serialize = node => { if (Text.isText(node)) { const string = node.text; @@ -107,6 +139,8 @@ const serialize = node => { return `

${children}

`; case 'image': return `${node.alt}`; + case 'mention': + return `@${node.name} `; default: return children; } @@ -116,6 +150,8 @@ const getCharacterCount = node => { if (Text.isText(node)) { return node.text.length; } + + // Hard code each image to count as 5 characters. if (node.type === 'image') { return 5; } @@ -132,15 +168,29 @@ export type ChatTextFieldProps = { defaultText?: string; enabled: boolean; focusInput: boolean; + knownChatUserDisplayNames?: string[]; }; const characterLimit = 300; -export const ChatTextField: FC = ({ defaultText, enabled, focusInput }) => { +export const ChatTextField: FC = ({ + defaultText, + enabled, + focusInput, + knownChatUserDisplayNames, +}) => { const [showEmojis, setShowEmojis] = useState(false); const [characterCount, setCharacterCount] = useState(defaultText?.length); + const [showingAutoCompleteMenu, setShowingAutoCompleteMenu] = useState(false); const websocketService = useRecoilValue(websocketServiceAtom); - const editor = useMemo(() => withReact(withImages(createEditor())), []); + const editor = useMemo(() => withReact(withMentions(withImages(createEditor()))), []); + const inputRef = useRef(null); + + const [search, setSearch] = useState(''); + + const chatUserNames = knownChatUserDisplayNames + ?.filter(c => c.toLowerCase().startsWith(search.toLowerCase())) + .slice(0, 10); const defaultEditorValue: Descendant[] = [ { @@ -187,6 +237,16 @@ export const ChatTextField: FC = ({ defaultText, enabled, fo Editor.normalize(editor, { force: true }); }; + const insertMention = (e, chatDisplayName) => { + const mention: MentionElement = { + type: 'mention', + name: chatDisplayName, + children: [{ text: '' }], + }; + Transforms.insertNodes(e, mention); + Transforms.move(e); + }; + // Native emoji const onEmojiSelect = (emoji: string) => { ReactEditor.focus(editor); @@ -198,29 +258,32 @@ export const ChatTextField: FC = ({ defaultText, enabled, fo insertImage(emoji, name); }; - const onKeyDown = (e: React.KeyboardEvent) => { - const charCount = getCharacterCount(editor) + 1; + const onKeyDown = useCallback( + e => { + const charCount = getCharacterCount(editor) + 1; - // Send the message when hitting enter. - if (e.key === 'Enter') { - e.preventDefault(); - sendMessage(); - return; - } + // Send the message when hitting enter. + if (e.key === 'Enter') { + e.preventDefault(); + sendMessage(); + return; + } - // Always allow backspace. - if (e.key === 'Backspace') { - setCharacterCount(charCount - 1); - return; - } + // Always allow backspace. + if (e.key === 'Backspace') { + setCharacterCount(charCount - 1); + return; + } - // Limit the number of characters. - if (charCount + 1 > characterLimit) { - e.preventDefault(); - } + // Limit the number of characters. + if (charCount + 1 > characterLimit) { + e.preventDefault(); + } - setCharacterCount(charCount + 1); - }; + setCharacterCount(charCount + 1); + }, + [editor], + ); const onPaste = (e: React.ClipboardEvent) => { const text = e.clipboardData.getData('text/plain'); @@ -235,6 +298,10 @@ export const ChatTextField: FC = ({ defaultText, enabled, fo switch (p.element.type) { case 'image': return ; + + case 'mention': + return ; + default: return

; } @@ -243,12 +310,36 @@ export const ChatTextField: FC = ({ defaultText, enabled, fo return (

= characterLimit && styles.maxCharacters, )} > - + { + const { selection } = editor; + + if (selection && Range.isCollapsed(selection)) { + const [start] = Range.edges(selection); + const wordBefore = Editor.before(editor, start, { unit: 'word' }); + const before = wordBefore && Editor.before(editor, wordBefore); + const beforeRange = before && Editor.range(editor, before, start); + const beforeText = beforeRange && Editor.string(editor, beforeRange); + const beforeMatch = beforeText && beforeText.match(/^@(\w+)$/); + const after = Editor.after(editor, start); + const afterRange = Editor.range(editor, start, after); + const afterText = Editor.string(editor, afterRange); + const afterMatch = afterText.match(/^(\s|$)/); + + if (beforeMatch && afterMatch) { + setShowingAutoCompleteMenu(true); + } + } + }} + > = ({ defaultText, enabled, fo onOpenChange={open => setShowEmojis(open)} open={showEmojis} /> + {showingAutoCompleteMenu && ( +