WIP chat user auto complete

This commit is contained in:
Gabe Kangas 2023-04-17 14:26:17 -07:00
parent 4c873d1ac2
commit 95c9239814
No known key found for this signature in database
GPG key ID: 4345B2060657F330
8 changed files with 219 additions and 29 deletions

View file

@ -30,6 +30,7 @@ export type ChatContainerProps = {
height?: string; height?: string;
chatAvailable: boolean; chatAvailable: boolean;
focusInput?: boolean; focusInput?: boolean;
knownChatUserDisplayNames?: string[];
}; };
function shouldCollapseMessages( function shouldCollapseMessages(
@ -92,6 +93,7 @@ export const ChatContainer: FC<ChatContainerProps> = ({
isModerator, isModerator,
showInput, showInput,
height, height,
knownChatUserDisplayNames,
chatAvailable: chatEnabled, chatAvailable: chatEnabled,
focusInput = true, focusInput = true,
}) => { }) => {
@ -285,7 +287,11 @@ export const ChatContainer: FC<ChatContainerProps> = ({
{MessagesTable} {MessagesTable}
{showInput && ( {showInput && (
<div className={styles.chatTextField}> <div className={styles.chatTextField}>
<ChatTextField enabled={chatEnabled} focusInput={focusInput} /> <ChatTextField
enabled={chatEnabled}
knownChatUserDisplayNames={knownChatUserDisplayNames}
focusInput={focusInput}
/>
</div> </div>
)} )}
</div> </div>

View file

@ -35,14 +35,14 @@
div[role='textbox'] { div[role='textbox'] {
font-size: 13px; font-size: 13px;
font-weight: 400; font-weight: 400;
padding: 0.3rem; padding: 0.3rem;
background-color: inherit; background-color: inherit;
border-color: var(--theme-color-components-form-field-border); border-color: var(--theme-color-components-form-field-border);
box-shadow: 0; box-shadow: 0;
transition: box-shadow 50ms ease-in-out; transition: box-shadow 50ms ease-in-out;
&:focus { &:focus {
outline: 1px solid var(--color-owncast-gray-500) !important; outline: 1px solid var(--theme-color-components-form-field-background) !important;
} }
& > p { & > p {
margin: 0px; margin: 0px;
@ -54,7 +54,7 @@
border: none; border: none;
background: none; background: none;
cursor: pointer; cursor: pointer;
padding: 0 .25rem; padding: 0 0.25rem;
} }
.sendButton { .sendButton {
@ -63,3 +63,32 @@
cursor: pointer; cursor: pointer;
padding: 0 1rem; 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;
}
}
}

View file

@ -1,5 +1,6 @@
import { Popover } from 'antd'; /* eslint-disable jsx-a11y/no-static-element-interactions */
import React, { FC, useMemo, useState } from 'react'; import { Popover, Select } from 'antd';
import React, { FC, useCallback, useMemo, useRef, useState } from 'react';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import { Transforms, createEditor, BaseEditor, Text, Descendant, Editor } from 'slate'; import { Transforms, createEditor, BaseEditor, Text, Descendant, Editor } from 'slate';
import { import {
@ -32,7 +33,10 @@ const SmileOutlined = dynamic(() => import('@ant-design/icons/SmileOutlined'), {
ssr: false, ssr: false,
}); });
type CustomElement = { type: 'paragraph' | 'span'; children: CustomText[] } | ImageNode; type CustomElement =
| { type: 'paragraph' | 'span'; children: CustomText[] }
| ImageNode
| MentionElement;
type CustomText = { text: string }; type CustomText = { text: string };
type EmptyText = { type EmptyText = {
@ -47,6 +51,12 @@ type ImageNode = {
children: EmptyText[]; children: EmptyText[];
}; };
type MentionElement = {
type: 'mention';
name: string;
children: CustomText[];
};
declare module 'slate' { declare module 'slate' {
interface CustomTypes { interface CustomTypes {
Editor: BaseEditor & ReactEditor; Editor: BaseEditor & ReactEditor;
@ -78,6 +88,17 @@ const Image = p => {
); );
}; };
const Mention = p => {
const { attributes, element, children } = p;
return (
<span {...attributes} className={styles.userMention} contentEditable={false}>
@{element.name}&nbsp;
{children}
</span>
);
};
const withImages = editor => { const withImages = editor => {
const { isVoid } = editor; const { isVoid } = editor;
@ -89,6 +110,17 @@ const withImages = editor => {
return 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 => { const serialize = node => {
if (Text.isText(node)) { if (Text.isText(node)) {
const string = node.text; const string = node.text;
@ -107,6 +139,8 @@ const serialize = node => {
return `<p>${children}</p>`; return `<p>${children}</p>`;
case 'image': case 'image':
return `<img src="${node.src}" alt="${node.alt}" title="${node.name}" class="emoji"/>`; return `<img src="${node.src}" alt="${node.alt}" title="${node.name}" class="emoji"/>`;
case 'mention':
return `@${node.name}&nbsp;`;
default: default:
return children; return children;
} }
@ -116,6 +150,8 @@ const getCharacterCount = node => {
if (Text.isText(node)) { if (Text.isText(node)) {
return node.text.length; return node.text.length;
} }
// Hard code each image to count as 5 characters.
if (node.type === 'image') { if (node.type === 'image') {
return 5; return 5;
} }
@ -132,15 +168,29 @@ export type ChatTextFieldProps = {
defaultText?: string; defaultText?: string;
enabled: boolean; enabled: boolean;
focusInput: boolean; focusInput: boolean;
knownChatUserDisplayNames?: string[];
}; };
const characterLimit = 300; const characterLimit = 300;
export const ChatTextField: FC<ChatTextFieldProps> = ({ defaultText, enabled, focusInput }) => { export const ChatTextField: FC<ChatTextFieldProps> = ({
defaultText,
enabled,
focusInput,
knownChatUserDisplayNames,
}) => {
const [showEmojis, setShowEmojis] = useState(false); const [showEmojis, setShowEmojis] = useState(false);
const [characterCount, setCharacterCount] = useState(defaultText?.length); const [characterCount, setCharacterCount] = useState(defaultText?.length);
const [showingAutoCompleteMenu, setShowingAutoCompleteMenu] = useState(false);
const websocketService = useRecoilValue<WebsocketService>(websocketServiceAtom); const websocketService = useRecoilValue<WebsocketService>(websocketServiceAtom);
const editor = useMemo(() => withReact(withImages(createEditor())), []); const editor = useMemo(() => withReact(withMentions(withImages(createEditor()))), []);
const inputRef = useRef<HTMLDivElement>(null);
const [search, setSearch] = useState('');
const chatUserNames = knownChatUserDisplayNames
?.filter(c => c.toLowerCase().startsWith(search.toLowerCase()))
.slice(0, 10);
const defaultEditorValue: Descendant[] = [ const defaultEditorValue: Descendant[] = [
{ {
@ -187,6 +237,16 @@ export const ChatTextField: FC<ChatTextFieldProps> = ({ defaultText, enabled, fo
Editor.normalize(editor, { force: true }); 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 // Native emoji
const onEmojiSelect = (emoji: string) => { const onEmojiSelect = (emoji: string) => {
ReactEditor.focus(editor); ReactEditor.focus(editor);
@ -198,29 +258,32 @@ export const ChatTextField: FC<ChatTextFieldProps> = ({ defaultText, enabled, fo
insertImage(emoji, name); insertImage(emoji, name);
}; };
const onKeyDown = (e: React.KeyboardEvent) => { const onKeyDown = useCallback(
const charCount = getCharacterCount(editor) + 1; e => {
const charCount = getCharacterCount(editor) + 1;
// Send the message when hitting enter. // Send the message when hitting enter.
if (e.key === 'Enter') { if (e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
sendMessage(); sendMessage();
return; return;
} }
// Always allow backspace. // Always allow backspace.
if (e.key === 'Backspace') { if (e.key === 'Backspace') {
setCharacterCount(charCount - 1); setCharacterCount(charCount - 1);
return; return;
} }
// Limit the number of characters. // Limit the number of characters.
if (charCount + 1 > characterLimit) { if (charCount + 1 > characterLimit) {
e.preventDefault(); e.preventDefault();
} }
setCharacterCount(charCount + 1); setCharacterCount(charCount + 1);
}; },
[editor],
);
const onPaste = (e: React.ClipboardEvent) => { const onPaste = (e: React.ClipboardEvent) => {
const text = e.clipboardData.getData('text/plain'); const text = e.clipboardData.getData('text/plain');
@ -235,6 +298,10 @@ export const ChatTextField: FC<ChatTextFieldProps> = ({ defaultText, enabled, fo
switch (p.element.type) { switch (p.element.type) {
case 'image': case 'image':
return <Image {...p} />; return <Image {...p} />;
case 'mention':
return <Mention {...p} />;
default: default:
return <p {...p} />; return <p {...p} />;
} }
@ -243,12 +310,36 @@ export const ChatTextField: FC<ChatTextFieldProps> = ({ defaultText, enabled, fo
return ( return (
<div id="chat-input" className={styles.root}> <div id="chat-input" className={styles.root}>
<div <div
ref={inputRef}
className={classNames( className={classNames(
styles.inputWrap, styles.inputWrap,
characterCount >= characterLimit && styles.maxCharacters, characterCount >= characterLimit && styles.maxCharacters,
)} )}
> >
<Slate editor={editor} initialValue={defaultEditorValue}> <Slate
editor={editor}
initialValue={defaultEditorValue}
onChange={() => {
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);
}
}
}}
>
<Editable <Editable
className="chat-text-input" className="chat-text-input"
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
@ -284,6 +375,34 @@ export const ChatTextField: FC<ChatTextFieldProps> = ({ defaultText, enabled, fo
onOpenChange={open => setShowEmojis(open)} onOpenChange={open => setShowEmojis(open)}
open={showEmojis} open={showEmojis}
/> />
{showingAutoCompleteMenu && (
<Select
defaultOpen
open
showArrow={false}
bordered={false}
dropdownMatchSelectWidth={false}
placement="topRight"
className={styles.autocompleteSelectMenu}
options={chatUserNames?.map(char => ({
value: char,
label: char,
}))}
onSelect={value => {
// Transforms.select(editor, target);
insertMention(editor, value);
setShowingAutoCompleteMenu(false);
}}
onInputKeyDown={e => {
if (e.key === 'Escape') {
setShowingAutoCompleteMenu(false);
}
}}
// style={{ zIndex: 9999 }}
getPopupContainer={() => inputRef.current}
onBlur={() => setShowingAutoCompleteMenu(false)}
/>
)}
</Slate> </Slate>
{enabled && ( {enabled && (

View file

@ -1,3 +1,4 @@
/* eslint-disable no-case-declarations */
import { FC, useContext, useEffect, useState } from 'react'; import { FC, useContext, useEffect, useState } from 'react';
import { atom, selector, useRecoilState, useSetRecoilState, RecoilEnv } from 'recoil'; import { atom, selector, useRecoilState, useSetRecoilState, RecoilEnv } from 'recoil';
import { useMachine } from '@xstate/react'; import { useMachine } from '@xstate/react';
@ -113,6 +114,11 @@ const removedMessageIdsAtom = atom<string[]>({
default: [], default: [],
}); });
export const knownChatUserDisplayNamesAtom = atom<string[]>({
key: 'knownChatUserDisplayNames',
default: [],
});
export const isChatAvailableSelector = selector({ export const isChatAvailableSelector = selector({
key: 'isChatAvailableSelector', key: 'isChatAvailableSelector',
get: ({ get }) => { get: ({ get }) => {
@ -171,6 +177,7 @@ export const ClientConfigStore: FC = () => {
const setGlobalFatalErrorMessage = useSetRecoilState<DisplayableError>(fatalErrorStateAtom); const setGlobalFatalErrorMessage = useSetRecoilState<DisplayableError>(fatalErrorStateAtom);
const setWebsocketService = useSetRecoilState<WebsocketService>(websocketServiceAtom); const setWebsocketService = useSetRecoilState<WebsocketService>(websocketServiceAtom);
const [hiddenMessageIds, setHiddenMessageIds] = useRecoilState<string[]>(removedMessageIdsAtom); const [hiddenMessageIds, setHiddenMessageIds] = useRecoilState<string[]>(removedMessageIdsAtom);
const setKnownChatUserDisplayNames = useSetRecoilState<string[]>(knownChatUserDisplayNamesAtom);
const [hasLoadedConfig, setHasLoadedConfig] = useState(false); const [hasLoadedConfig, setHasLoadedConfig] = useState(false);
let ws: WebsocketService; let ws: WebsocketService;
@ -291,6 +298,10 @@ export const ClientConfigStore: FC = () => {
hasWebsocketDisconnected = false; hasWebsocketDisconnected = false;
}; };
const handleChatUserDisplayNameAdd = (displayName: string) => {
setKnownChatUserDisplayNames(currentState => [...currentState, displayName]);
};
const handleMessage = (message: SocketEvent) => { const handleMessage = (message: SocketEvent) => {
switch (message.type) { switch (message.type) {
case MessageType.ERROR_NEEDS_REGISTRATION: case MessageType.ERROR_NEEDS_REGISTRATION:
@ -304,6 +315,7 @@ export const ClientConfigStore: FC = () => {
); );
if (message as ChatEvent) { if (message as ChatEvent) {
const m = new ChatEvent(message); const m = new ChatEvent(message);
handleChatUserDisplayNameAdd(m.user.displayName);
if (!hasBeenModeratorNotified && m.user?.isModerator) { if (!hasBeenModeratorNotified && m.user?.isModerator) {
setChatMessages(currentState => [...currentState, message as ChatEvent]); setChatMessages(currentState => [...currentState, message as ChatEvent]);
hasBeenModeratorNotified = true; hasBeenModeratorNotified = true;
@ -313,6 +325,8 @@ export const ClientConfigStore: FC = () => {
break; break;
case MessageType.CHAT: case MessageType.CHAT:
setChatMessages(currentState => [...currentState, message as ChatEvent]); setChatMessages(currentState => [...currentState, message as ChatEvent]);
const m = new ChatEvent(message);
handleChatUserDisplayNameAdd(m.user.displayName);
break; break;
case MessageType.NAME_CHANGE: case MessageType.NAME_CHANGE:
handleNameChangeEvent(message as ChatEvent, setChatMessages); handleNameChangeEvent(message as ChatEvent, setChatMessages);

View file

@ -17,7 +17,11 @@ import {
isOnlineSelector, isOnlineSelector,
isMobileAtom, isMobileAtom,
serverStatusState, serverStatusState,
<<<<<<< HEAD
isChatAvailableSelector, isChatAvailableSelector,
=======
knownChatUserDisplayNamesAtom,
>>>>>>> 490f5dc79 (WIP chat user auto complete)
} from '../../stores/ClientConfigStore'; } from '../../stores/ClientConfigStore';
import { ClientConfig } from '../../../interfaces/client-config.model'; import { ClientConfig } from '../../../interfaces/client-config.model';
@ -98,7 +102,11 @@ export const Content: FC = () => {
const [isMobile, setIsMobile] = useRecoilState<boolean | undefined>(isMobileAtom); const [isMobile, setIsMobile] = useRecoilState<boolean | undefined>(isMobileAtom);
const messages = useRecoilValue<ChatMessage[]>(chatMessagesAtom); const messages = useRecoilValue<ChatMessage[]>(chatMessagesAtom);
const online = useRecoilValue<boolean>(isOnlineSelector); const online = useRecoilValue<boolean>(isOnlineSelector);
<<<<<<< HEAD
const isChatAvailable = useRecoilValue<boolean>(isChatAvailableSelector); const isChatAvailable = useRecoilValue<boolean>(isChatAvailableSelector);
=======
const knownChatUserDisplayNames = useRecoilValue(knownChatUserDisplayNamesAtom);
>>>>>>> 490f5dc79 (WIP chat user auto complete)
const { viewerCount, lastConnectTime, lastDisconnectTime, streamTitle } = const { viewerCount, lastConnectTime, lastDisconnectTime, streamTitle } =
useRecoilValue<ServerStatus>(serverStatusState); useRecoilValue<ServerStatus>(serverStatusState);
@ -268,7 +276,12 @@ export const Content: FC = () => {
extraPageContent={extraPageContent} extraPageContent={extraPageContent}
setShowFollowModal={setShowFollowModal} setShowFollowModal={setShowFollowModal}
supportFediverseFeatures={supportFediverseFeatures} supportFediverseFeatures={supportFediverseFeatures}
<<<<<<< HEAD
online={online} online={online}
=======
chatEnabled={isChatAvailable}
knownChatUserDisplayNames={knownChatUserDisplayNames}
>>>>>>> 490f5dc79 (WIP chat user auto complete)
/> />
) : ( ) : (
<Col span={24} style={{ paddingRight: dynamicPadding }}> <Col span={24} style={{ paddingRight: dynamicPadding }}>

View file

@ -8,6 +8,7 @@ import styles from './Sidebar.module.scss';
import { import {
currentUserAtom, currentUserAtom,
knownChatUserDisplayNamesAtom,
visibleChatMessagesSelector, visibleChatMessagesSelector,
isChatAvailableSelector, isChatAvailableSelector,
} from '../../stores/ClientConfigStore'; } from '../../stores/ClientConfigStore';
@ -24,6 +25,7 @@ export const Sidebar: FC = () => {
const currentUser = useRecoilValue(currentUserAtom); const currentUser = useRecoilValue(currentUserAtom);
const messages = useRecoilValue<ChatMessage[]>(visibleChatMessagesSelector); const messages = useRecoilValue<ChatMessage[]>(visibleChatMessagesSelector);
const isChatAvailable = useRecoilValue(isChatAvailableSelector); const isChatAvailable = useRecoilValue(isChatAvailableSelector);
const knownChatUserDisplayNames = useRecoilValue(knownChatUserDisplayNamesAtom);
if (!currentUser) { if (!currentUser) {
return ( return (
@ -43,6 +45,7 @@ export const Sidebar: FC = () => {
isModerator={isModerator} isModerator={isModerator}
chatAvailable={isChatAvailable} chatAvailable={isChatAvailable}
showInput={!!currentUser} showInput={!!currentUser}
knownChatUserDisplayNames={knownChatUserDisplayNames}
/> />
</Sider> </Sider>
); );

View file

@ -5,6 +5,7 @@ import { ChatContainer } from '../../../../components/chat/ChatContainer/ChatCon
import { import {
ClientConfigStore, ClientConfigStore,
currentUserAtom, currentUserAtom,
knownChatUserDisplayNamesAtom,
visibleChatMessagesSelector, visibleChatMessagesSelector,
isChatAvailableSelector, isChatAvailableSelector,
} from '../../../../components/stores/ClientConfigStore'; } from '../../../../components/stores/ClientConfigStore';
@ -15,6 +16,7 @@ export default function ReadOnlyChatEmbed() {
const currentUser = useRecoilValue(currentUserAtom); const currentUser = useRecoilValue(currentUserAtom);
const messages = useRecoilValue<ChatMessage[]>(visibleChatMessagesSelector); const messages = useRecoilValue<ChatMessage[]>(visibleChatMessagesSelector);
const isChatAvailable = useRecoilValue(isChatAvailableSelector); const isChatAvailable = useRecoilValue(isChatAvailableSelector);
const knownChatUserDisplayNames = useRecoilValue(knownChatUserDisplayNamesAtom);
return ( return (
<div> <div>
@ -35,6 +37,7 @@ export default function ReadOnlyChatEmbed() {
showInput={false} showInput={false}
height="100vh" height="100vh"
chatAvailable={isChatAvailable} chatAvailable={isChatAvailable}
knownChatUserDisplayNames={knownChatUserDisplayNames}
/> />
)} )}
</ErrorBoundary> </ErrorBoundary>

View file

@ -10,6 +10,7 @@ import {
visibleChatMessagesSelector, visibleChatMessagesSelector,
clientConfigStateAtom, clientConfigStateAtom,
appStateAtom, appStateAtom,
knownChatUserDisplayNamesAtom,
serverStatusState, serverStatusState,
isChatAvailableSelector, isChatAvailableSelector,
} from '../../../../components/stores/ClientConfigStore'; } from '../../../../components/stores/ClientConfigStore';
@ -28,6 +29,7 @@ export default function ReadWriteChatEmbed() {
const appState = useRecoilValue<AppStateOptions>(appStateAtom); const appState = useRecoilValue<AppStateOptions>(appStateAtom);
const isChatAvailable = useRecoilValue(isChatAvailableSelector); const isChatAvailable = useRecoilValue(isChatAvailableSelector);
const knownChatUserDisplayNames = useRecoilValue(knownChatUserDisplayNamesAtom);
const { name, chatDisabled } = clientConfig; const { name, chatDisabled } = clientConfig;
const { videoAvailable } = appState; const { videoAvailable } = appState;
@ -73,6 +75,7 @@ export default function ReadWriteChatEmbed() {
showInput showInput
height="92vh" height="92vh"
chatAvailable={isChatAvailable} chatAvailable={isChatAvailable}
knownChatUserDisplayNames={knownChatUserDisplayNames}
/> />
</div> </div>
)} )}