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

View file

@ -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;
}
}
}

View file

@ -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 (
<span {...attributes} className={styles.userMention} contentEditable={false}>
@{element.name}&nbsp;
{children}
</span>
);
};
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 `<p>${children}</p>`;
case 'image':
return `<img src="${node.src}" alt="${node.alt}" title="${node.name}" class="emoji"/>`;
case 'mention':
return `@${node.name}&nbsp;`;
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<ChatTextFieldProps> = ({ defaultText, enabled, focusInput }) => {
export const ChatTextField: FC<ChatTextFieldProps> = ({
defaultText,
enabled,
focusInput,
knownChatUserDisplayNames,
}) => {
const [showEmojis, setShowEmojis] = useState(false);
const [characterCount, setCharacterCount] = useState(defaultText?.length);
const [showingAutoCompleteMenu, setShowingAutoCompleteMenu] = useState(false);
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[] = [
{
@ -187,6 +237,16 @@ export const ChatTextField: FC<ChatTextFieldProps> = ({ 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<ChatTextFieldProps> = ({ 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<ChatTextFieldProps> = ({ defaultText, enabled, fo
switch (p.element.type) {
case 'image':
return <Image {...p} />;
case 'mention':
return <Mention {...p} />;
default:
return <p {...p} />;
}
@ -243,12 +310,36 @@ export const ChatTextField: FC<ChatTextFieldProps> = ({ defaultText, enabled, fo
return (
<div id="chat-input" className={styles.root}>
<div
ref={inputRef}
className={classNames(
styles.inputWrap,
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
className="chat-text-input"
onKeyDown={onKeyDown}
@ -284,6 +375,34 @@ export const ChatTextField: FC<ChatTextFieldProps> = ({ defaultText, enabled, fo
onOpenChange={open => setShowEmojis(open)}
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>
{enabled && (

View file

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

View file

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

View file

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

View file

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

View file

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