mirror of
https://github.com/owncast/owncast.git
synced 2024-11-22 04:40:37 +03:00
WIP chat user auto complete
This commit is contained in:
parent
4c873d1ac2
commit
95c9239814
8 changed files with 219 additions and 29 deletions
|
@ -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>
|
||||
|
|
|
@ -42,7 +42,7 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
{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} `;
|
||||
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,7 +258,8 @@ export const ChatTextField: FC<ChatTextFieldProps> = ({ defaultText, enabled, fo
|
|||
insertImage(emoji, name);
|
||||
};
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||
const onKeyDown = useCallback(
|
||||
e => {
|
||||
const charCount = getCharacterCount(editor) + 1;
|
||||
|
||||
// Send the message when hitting enter.
|
||||
|
@ -220,7 +281,9 @@ export const ChatTextField: FC<ChatTextFieldProps> = ({ defaultText, enabled, fo
|
|||
}
|
||||
|
||||
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 && (
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 }}>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
|
|
Loading…
Reference in a new issue