mirror of
				https://github.com/owncast/owncast.git
				synced 2025-11-04 13:27:21 +08:00 
			
		
		
		
	
		
			
				
	
	
		
			296 lines
		
	
	
		
			7.8 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			296 lines
		
	
	
		
			7.8 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
import { Popover } from 'antd';
 | 
						|
import React, { FC, useReducer, useRef, useState } from 'react';
 | 
						|
import { useRecoilValue } from 'recoil';
 | 
						|
import ContentEditable from 'react-contenteditable';
 | 
						|
import dynamic from 'next/dynamic';
 | 
						|
import classNames from 'classnames';
 | 
						|
import WebsocketService from '../../../services/websocket-service';
 | 
						|
import { websocketServiceAtom } from '../../stores/ClientConfigStore';
 | 
						|
import { MessageType } from '../../../interfaces/socket-events';
 | 
						|
import styles from './ChatTextField.module.scss';
 | 
						|
 | 
						|
// Lazy loaded components
 | 
						|
 | 
						|
const EmojiPicker = dynamic(() => import('./EmojiPicker').then(mod => mod.EmojiPicker), {
 | 
						|
  ssr: false,
 | 
						|
});
 | 
						|
 | 
						|
const SendOutlined = dynamic(() => import('@ant-design/icons/SendOutlined'), {
 | 
						|
  ssr: false,
 | 
						|
});
 | 
						|
 | 
						|
const SmileOutlined = dynamic(() => import('@ant-design/icons/SmileOutlined'), {
 | 
						|
  ssr: false,
 | 
						|
});
 | 
						|
 | 
						|
export type ChatTextFieldProps = {
 | 
						|
  defaultText?: string;
 | 
						|
  enabled: boolean;
 | 
						|
  focusInput: boolean;
 | 
						|
};
 | 
						|
 | 
						|
const characterLimit = 300;
 | 
						|
 | 
						|
function getCaretPosition(node) {
 | 
						|
  const selection = window.getSelection();
 | 
						|
 | 
						|
  if (selection.rangeCount === 0) {
 | 
						|
    return 0;
 | 
						|
  }
 | 
						|
 | 
						|
  const range = selection.getRangeAt(0);
 | 
						|
  const preCaretRange = range.cloneRange();
 | 
						|
  const tempElement = document.createElement('div');
 | 
						|
 | 
						|
  preCaretRange.selectNodeContents(node);
 | 
						|
  preCaretRange.setEnd(range.endContainer, range.endOffset);
 | 
						|
  tempElement.appendChild(preCaretRange.cloneContents());
 | 
						|
 | 
						|
  return tempElement.innerHTML.length;
 | 
						|
}
 | 
						|
 | 
						|
function setCaretPosition(editableDiv, position) {
 | 
						|
  try {
 | 
						|
    const range = document.createRange();
 | 
						|
    const sel = window.getSelection();
 | 
						|
    range.selectNode(editableDiv);
 | 
						|
    range.setStart(editableDiv.childNodes[0], position);
 | 
						|
    range.collapse(true);
 | 
						|
 | 
						|
    sel.removeAllRanges();
 | 
						|
    sel.addRange(range);
 | 
						|
  } catch (e) {
 | 
						|
    console.debug(e);
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
function convertToText(str = '') {
 | 
						|
  // Ensure string.
 | 
						|
  let value = String(str);
 | 
						|
 | 
						|
  // Convert encoding.
 | 
						|
  value = value.replace(/ /gi, ' ');
 | 
						|
  value = value.replace(/&/gi, '&');
 | 
						|
 | 
						|
  // Replace `<br>`.
 | 
						|
  value = value.replace(/<br>/gi, '\n');
 | 
						|
 | 
						|
  // Replace `<div>` (from Chrome).
 | 
						|
  value = value.replace(/<div>/gi, '\n');
 | 
						|
 | 
						|
  // Replace `<p>` (from IE).
 | 
						|
  value = value.replace(/<p>/gi, '\n');
 | 
						|
 | 
						|
  // Cleanup the emoji titles.
 | 
						|
  value = value.replace(/\u200C{2}/gi, '');
 | 
						|
 | 
						|
  // Trim each line.
 | 
						|
  value = value
 | 
						|
    .split('\n')
 | 
						|
    .map((line = '') => line.trim())
 | 
						|
    .join('\n');
 | 
						|
 | 
						|
  // No more than 2x newline, per "paragraph".
 | 
						|
  value = value.replace(/\n\n+/g, '\n\n');
 | 
						|
 | 
						|
  // Clean up spaces.
 | 
						|
  value = value.replace(/[ ]+/g, ' ');
 | 
						|
  value = value.trim();
 | 
						|
 | 
						|
  // Expose string.
 | 
						|
  return value;
 | 
						|
}
 | 
						|
 | 
						|
export const ChatTextField: FC<ChatTextFieldProps> = ({ defaultText, enabled, focusInput }) => {
 | 
						|
  const [showEmojis, setShowEmojis] = useState(false);
 | 
						|
  const [characterCount, setCharacterCount] = useState(defaultText?.length);
 | 
						|
  const websocketService = useRecoilValue<WebsocketService>(websocketServiceAtom);
 | 
						|
  const text = useRef(defaultText || '');
 | 
						|
  const [savedCursorLocation, setSavedCursorLocation] = useState(0);
 | 
						|
 | 
						|
  // 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.
 | 
						|
  const [, forceUpdate] = useReducer(x => x + 1, 0);
 | 
						|
 | 
						|
  const getCharacterCount = () => text.current.length;
 | 
						|
 | 
						|
  const sendMessage = () => {
 | 
						|
    if (!websocketService) {
 | 
						|
      console.log('websocketService is not defined');
 | 
						|
      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 });
 | 
						|
 | 
						|
    // Clear the input.
 | 
						|
    text.current = '';
 | 
						|
    setCharacterCount(0);
 | 
						|
    forceUpdate();
 | 
						|
  };
 | 
						|
 | 
						|
  const insertTextAtCursor = (textToInsert: string) => {
 | 
						|
    const output = [
 | 
						|
      text.current.slice(0, savedCursorLocation),
 | 
						|
      textToInsert,
 | 
						|
      text.current.slice(savedCursorLocation),
 | 
						|
    ].join('');
 | 
						|
    text.current = output;
 | 
						|
    forceUpdate();
 | 
						|
  };
 | 
						|
 | 
						|
  const convertOnPaste = (event: React.ClipboardEvent) => {
 | 
						|
    // Prevent paste.
 | 
						|
    event.preventDefault();
 | 
						|
 | 
						|
    // Set later.
 | 
						|
    let value = '';
 | 
						|
 | 
						|
    // Does method exist?
 | 
						|
    const hasEventClipboard = !!(
 | 
						|
      event.clipboardData &&
 | 
						|
      typeof event.clipboardData === 'object' &&
 | 
						|
      typeof event.clipboardData.getData === 'function'
 | 
						|
    );
 | 
						|
 | 
						|
    // Get clipboard data?
 | 
						|
    if (hasEventClipboard) {
 | 
						|
      value = event.clipboardData.getData('text/plain');
 | 
						|
    }
 | 
						|
 | 
						|
    // Insert into temp `<textarea>`, read back out.
 | 
						|
    const textarea = document.createElement('textarea');
 | 
						|
    textarea.innerHTML = value;
 | 
						|
    value = textarea.innerText;
 | 
						|
 | 
						|
    // Clean up text.
 | 
						|
    value = convertToText(value);
 | 
						|
 | 
						|
    // Insert text.
 | 
						|
    insertTextAtCursor(value);
 | 
						|
  };
 | 
						|
 | 
						|
  // Native emoji
 | 
						|
  const onEmojiSelect = (emoji: string) => {
 | 
						|
    insertTextAtCursor(emoji);
 | 
						|
  };
 | 
						|
 | 
						|
  // Custom emoji images
 | 
						|
  const onCustomEmojiSelect = (name: string, emoji: string) => {
 | 
						|
    const html = `<img src="${emoji}" alt="${name}" title=${name} class="emoji" />`;
 | 
						|
    insertTextAtCursor(html);
 | 
						|
  };
 | 
						|
 | 
						|
  const onKeyDown = (e: React.KeyboardEvent) => {
 | 
						|
    const charCount = getCharacterCount() + 1;
 | 
						|
 | 
						|
    // 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 delete.
 | 
						|
    if (e.key === 'Delete') {
 | 
						|
      setCharacterCount(charCount - 1);
 | 
						|
      return;
 | 
						|
    }
 | 
						|
 | 
						|
    // Always allow ctrl + a.
 | 
						|
    if (e.key === 'a' && e.ctrlKey) {
 | 
						|
      return;
 | 
						|
    }
 | 
						|
 | 
						|
    // Limit the number of characters.
 | 
						|
    if (charCount + 1 > characterLimit) {
 | 
						|
      e.preventDefault();
 | 
						|
    }
 | 
						|
    setCharacterCount(charCount + 1);
 | 
						|
  };
 | 
						|
 | 
						|
  const handleChange = evt => {
 | 
						|
    text.current = evt.target.value;
 | 
						|
  };
 | 
						|
 | 
						|
  const handleBlur = () => {
 | 
						|
    // Save the cursor location.
 | 
						|
    setSavedCursorLocation(
 | 
						|
      getCaretPosition(document.getElementById('chat-input-content-editable')),
 | 
						|
    );
 | 
						|
  };
 | 
						|
 | 
						|
  const handleFocus = () => {
 | 
						|
    if (!savedCursorLocation) {
 | 
						|
      return;
 | 
						|
    }
 | 
						|
 | 
						|
    // Restore the cursor location.
 | 
						|
    setCaretPosition(document.getElementById('chat-input-content-editable'), savedCursorLocation);
 | 
						|
    setSavedCursorLocation(0);
 | 
						|
  };
 | 
						|
 | 
						|
  return (
 | 
						|
    <div id="chat-input" className={styles.root}>
 | 
						|
      <div
 | 
						|
        className={classNames(
 | 
						|
          styles.inputWrap,
 | 
						|
          characterCount >= characterLimit && styles.maxCharacters,
 | 
						|
        )}
 | 
						|
      >
 | 
						|
        <Popover
 | 
						|
          content={
 | 
						|
            <EmojiPicker onEmojiSelect={onEmojiSelect} onCustomEmojiSelect={onCustomEmojiSelect} />
 | 
						|
          }
 | 
						|
          trigger="click"
 | 
						|
          placement="topRight"
 | 
						|
          onOpenChange={open => setShowEmojis(open)}
 | 
						|
          open={showEmojis}
 | 
						|
        />
 | 
						|
        <ContentEditable
 | 
						|
          id="chat-input-content-editable"
 | 
						|
          html={text.current}
 | 
						|
          placeholder={enabled ? 'Send a message to chat' : 'Chat is disabled'}
 | 
						|
          disabled={!enabled}
 | 
						|
          onKeyDown={onKeyDown}
 | 
						|
          onPaste={convertOnPaste}
 | 
						|
          onChange={handleChange}
 | 
						|
          onBlur={handleBlur}
 | 
						|
          onFocus={handleFocus}
 | 
						|
          autoFocus={focusInput}
 | 
						|
          style={{ width: '100%' }}
 | 
						|
          role="textbox"
 | 
						|
          aria-label="Chat text input"
 | 
						|
        />
 | 
						|
        {enabled && (
 | 
						|
          <div style={{ display: 'flex', paddingLeft: '5px' }}>
 | 
						|
            <button
 | 
						|
              type="button"
 | 
						|
              className={styles.emojiButton}
 | 
						|
              title="Emoji picker button"
 | 
						|
              onClick={() => setShowEmojis(!showEmojis)}
 | 
						|
            >
 | 
						|
              <SmileOutlined />
 | 
						|
            </button>
 | 
						|
            <button
 | 
						|
              type="button"
 | 
						|
              className={styles.sendButton}
 | 
						|
              title="Send message Button"
 | 
						|
              onClick={sendMessage}
 | 
						|
            >
 | 
						|
              <SendOutlined />
 | 
						|
            </button>
 | 
						|
          </div>
 | 
						|
        )}
 | 
						|
      </div>
 | 
						|
    </div>
 | 
						|
  );
 | 
						|
};
 |