import type { ChangeEvent } from 'react'; import type { FC } from '../../../lib/teact/teact'; import React, { useEffect, useRef, memo, useState, useCallback, } from '../../../lib/teact/teact'; import { getActions, withGlobal } from '../../../global'; import type { IAnchorPosition, ISettings } from '../../../types'; import { EDITABLE_INPUT_ID } from '../../../config'; import { selectIsInSelectMode, selectReplyingToId } from '../../../global/selectors'; import { debounce } from '../../../util/schedulers'; import focusEditableElement from '../../../util/focusEditableElement'; import buildClassName from '../../../util/buildClassName'; import { IS_ANDROID, IS_EMOJI_SUPPORTED, IS_IOS, IS_SINGLE_COLUMN_LAYOUT, IS_TOUCH_ENV, } from '../../../util/environment'; import captureKeyboardListeners from '../../../util/captureKeyboardListeners'; import { getIsDirectTextInputDisabled } from '../../../util/directInputManager'; import useLayoutEffectWithPrevDeps from '../../../hooks/useLayoutEffectWithPrevDeps'; import useFlag from '../../../hooks/useFlag'; import { isHeavyAnimating } from '../../../hooks/useHeavyAnimationCheck'; import useLang from '../../../hooks/useLang'; import parseEmojiOnlyString from '../../../util/parseEmojiOnlyString'; import { isSelectionInsideInput } from './helpers/selection'; import renderText from '../../common/helpers/renderText'; import TextFormatter from './TextFormatter'; const CONTEXT_MENU_CLOSE_DELAY_MS = 100; // Focus slows down animation, also it breaks transition layout in Chrome const FOCUS_DELAY_MS = 350; const TRANSITION_DURATION_FACTOR = 50; type OwnProps = { id: string; chatId: string; threadId: number; isAttachmentModalInput?: boolean; editableInputId?: string; html: string; placeholder: string; forcedPlaceholder?: string; noFocusInterception?: boolean; canAutoFocus: boolean; shouldSuppressFocus?: boolean; shouldSuppressTextFormatter?: boolean; onUpdate: (html: string) => void; onSuppressedFocus?: () => void; onSend: () => void; captionLimit?: number; }; type StateProps = { replyingToId?: number; isSelectModeActive?: boolean; messageSendKeyCombo?: ISettings['messageSendKeyCombo']; }; const MAX_INPUT_HEIGHT = IS_SINGLE_COLUMN_LAYOUT ? 256 : 416; const TAB_INDEX_PRIORITY_TIMEOUT = 2000; // Heuristics allowing the user to make a triple click const SELECTION_RECALCULATE_DELAY_MS = 260; const TEXT_FORMATTER_SAFE_AREA_PX = 90; // For some reason Safari inserts `
` after user removes text from input const SAFARI_BR = '
'; const IGNORE_KEYS = ['Enter', 'PageUp', 'PageDown', 'Meta', 'Alt', 'Ctrl', 'ArrowDown', 'ArrowUp']; function clearSelection() { const selection = window.getSelection(); if (!selection) { return; } if (selection.removeAllRanges) { selection.removeAllRanges(); } else if (selection.empty) { selection.empty(); } } const MessageInput: FC = ({ id, chatId, captionLimit, isAttachmentModalInput, editableInputId, html, placeholder, forcedPlaceholder, canAutoFocus, noFocusInterception, shouldSuppressFocus, shouldSuppressTextFormatter, replyingToId, isSelectModeActive, messageSendKeyCombo, onUpdate, onSuppressedFocus, onSend, }) => { const { editLastMessage, replyToNextMessage, } = getActions(); // eslint-disable-next-line no-null/no-null const inputRef = useRef(null); // eslint-disable-next-line no-null/no-null const selectionTimeoutRef = useRef(null); // eslint-disable-next-line no-null/no-null const cloneRef = useRef(null); const lang = useLang(); const isContextMenuOpenRef = useRef(false); const [isTextFormatterOpen, openTextFormatter, closeTextFormatter] = useFlag(); const [textFormatterAnchorPosition, setTextFormatterAnchorPosition] = useState(); const [selectedRange, setSelectedRange] = useState(); const [isTextFormatterDisabled, setIsTextFormatterDisabled] = useState(false); useEffect(() => { if (!isAttachmentModalInput) return; updateInputHeight(false); }, [isAttachmentModalInput]); useLayoutEffectWithPrevDeps(([prevHtml]) => { if (html !== inputRef.current!.innerHTML) { inputRef.current!.innerHTML = html; } if (html !== cloneRef.current!.innerHTML) { cloneRef.current!.innerHTML = html; } if (prevHtml !== undefined && prevHtml !== html) { updateInputHeight(!html.length); } }, [html]); const chatIdRef = useRef(chatId); chatIdRef.current = chatId; const focusInput = useCallback(() => { if (!inputRef.current) { return; } if (isHeavyAnimating()) { setTimeout(focusInput, FOCUS_DELAY_MS); return; } focusEditableElement(inputRef.current!); }, []); const handleCloseTextFormatter = useCallback(() => { closeTextFormatter(); clearSelection(); }, [closeTextFormatter]); function checkSelection() { // Disable the formatter on iOS devices for now. if (IS_IOS) { return false; } const selection = window.getSelection(); if (!selection || !selection.rangeCount || isContextMenuOpenRef.current) { closeTextFormatter(); if (IS_ANDROID) { setIsTextFormatterDisabled(false); } return false; } const selectionRange = selection.getRangeAt(0); const selectedText = selectionRange.toString().trim(); if ( shouldSuppressTextFormatter || !isSelectionInsideInput(selectionRange, editableInputId || EDITABLE_INPUT_ID) || !selectedText || parseEmojiOnlyString(selectedText) || !selectionRange.START_TO_END ) { closeTextFormatter(); return false; } return true; } function processSelection() { if (!checkSelection()) { return; } if (isTextFormatterDisabled) { return; } const selectionRange = window.getSelection()!.getRangeAt(0); const selectionRect = selectionRange.getBoundingClientRect(); const inputRect = inputRef.current!.getBoundingClientRect(); let x = (selectionRect.left + selectionRect.width / 2) - inputRect.left; if (x < TEXT_FORMATTER_SAFE_AREA_PX) { x = TEXT_FORMATTER_SAFE_AREA_PX; } else if (x > inputRect.width - TEXT_FORMATTER_SAFE_AREA_PX) { x = inputRect.width - TEXT_FORMATTER_SAFE_AREA_PX; } setTextFormatterAnchorPosition({ x, y: selectionRect.top - inputRect.top, }); setSelectedRange(selectionRange); openTextFormatter(); } function processSelectionWithTimeout() { if (selectionTimeoutRef.current) { window.clearTimeout(selectionTimeoutRef.current); } // Small delay to allow browser properly recalculate selection selectionTimeoutRef.current = window.setTimeout(processSelection, SELECTION_RECALCULATE_DELAY_MS); } function handleMouseDown(e: React.MouseEvent) { if (e.button !== 2) { e.target.addEventListener('mouseup', processSelectionWithTimeout, { once: true }); return; } if (isContextMenuOpenRef.current) { return; } isContextMenuOpenRef.current = true; function handleCloseContextMenu(e2: KeyboardEvent | MouseEvent) { if (e2 instanceof KeyboardEvent && e2.key !== 'Esc' && e2.key !== 'Escape') { return; } setTimeout(() => { isContextMenuOpenRef.current = false; }, CONTEXT_MENU_CLOSE_DELAY_MS); window.removeEventListener('keydown', handleCloseContextMenu); window.removeEventListener('mousedown', handleCloseContextMenu); } document.addEventListener('mousedown', handleCloseContextMenu); document.addEventListener('keydown', handleCloseContextMenu); } function handleKeyDown(e: React.KeyboardEvent) { // https://levelup.gitconnected.com/javascript-events-handlers-keyboard-and-load-events-1b3e46a6b0c3#1960 const { isComposing } = e; if (!isComposing && !html.length && (e.metaKey || e.ctrlKey)) { const targetIndexDelta = e.key === 'ArrowDown' ? 1 : e.key === 'ArrowUp' ? -1 : undefined; if (targetIndexDelta) { e.preventDefault(); replyToNextMessage({ targetIndexDelta }); return; } } if (!isComposing && e.key === 'Enter' && !e.shiftKey) { if ( !(IS_IOS || IS_ANDROID) && ( (messageSendKeyCombo === 'enter' && !e.shiftKey) || (messageSendKeyCombo === 'ctrl-enter' && (e.ctrlKey || e.metaKey)) ) ) { e.preventDefault(); closeTextFormatter(); onSend(); } } else if (!isComposing && e.key === 'ArrowUp' && !html.length && !e.metaKey && !e.ctrlKey && !e.altKey) { e.preventDefault(); editLastMessage(); } else { e.target.addEventListener('keyup', processSelectionWithTimeout, { once: true }); } } function handleChange(e: ChangeEvent) { const { innerHTML, textContent } = e.currentTarget; onUpdate(innerHTML === SAFARI_BR ? '' : innerHTML); // Reset focus on the input to remove any active styling when input is cleared if ( !IS_TOUCH_ENV && (!textContent || !textContent.length) // When emojis are not supported, innerHTML contains an emoji img tag that doesn't exist in the textContext && !(!IS_EMOJI_SUPPORTED && innerHTML.includes('emoji-small')) && !(innerHTML.includes('custom-emoji')) ) { const selection = window.getSelection()!; if (selection) { inputRef.current!.blur(); selection.removeAllRanges(); focusEditableElement(inputRef.current!, true); } } } function handleAndroidContextMenu(e: React.MouseEvent) { if (!checkSelection()) { return; } setIsTextFormatterDisabled(!isTextFormatterDisabled); if (!isTextFormatterDisabled) { e.preventDefault(); e.stopPropagation(); processSelection(); } else { closeTextFormatter(); } } function updateInputHeight(willSend = false) { const input = inputRef.current!; const clone = cloneRef.current!; const currentHeight = Number(input.style.height.replace('px', '')); const newHeight = Math.min(clone.scrollHeight, MAX_INPUT_HEIGHT); if (newHeight === currentHeight) { return; } const transitionDuration = Math.round( TRANSITION_DURATION_FACTOR * Math.log(Math.abs(newHeight - currentHeight)), ); const exec = () => { input.style.height = `${newHeight}px`; input.style.transitionDuration = `${transitionDuration}ms`; input.classList.toggle('overflown', clone.scrollHeight > MAX_INPUT_HEIGHT); }; if (willSend) { // Sync with sending animation requestAnimationFrame(exec); } else { exec(); } } useEffect(() => { if (IS_TOUCH_ENV) { return; } if (canAutoFocus) { focusInput(); } }, [chatId, focusInput, replyingToId, canAutoFocus]); useEffect(() => { if ( !chatId || editableInputId !== EDITABLE_INPUT_ID || noFocusInterception || (IS_TOUCH_ENV && IS_SINGLE_COLUMN_LAYOUT) || isSelectModeActive ) { return undefined; } const handleDocumentKeyDown = (e: KeyboardEvent) => { if (getIsDirectTextInputDisabled()) { return; } const { key } = e; const target = e.target as HTMLElement | undefined; if (!target || IGNORE_KEYS.includes(key)) { return; } const input = inputRef.current!; const isSelectionCollapsed = document.getSelection()?.isCollapsed; if ( ((key.startsWith('Arrow') || (e.shiftKey && key === 'Shift')) && !isSelectionCollapsed) || (e.code === 'KeyC' && (e.ctrlKey || e.metaKey) && target.tagName !== 'INPUT') ) { return; } if ( input && target !== input && target.tagName !== 'INPUT' && !target.isContentEditable ) { focusEditableElement(input, true, true); const newEvent = new KeyboardEvent(e.type, e as any); input.dispatchEvent(newEvent); } }; document.addEventListener('keydown', handleDocumentKeyDown, true); return () => { document.removeEventListener('keydown', handleDocumentKeyDown, true); }; }, [chatId, editableInputId, isSelectModeActive, noFocusInterception]); useEffect(() => { const captureFirstTab = debounce((e: KeyboardEvent) => { if (e.key === 'Tab' && !getIsDirectTextInputDisabled()) { e.preventDefault(); requestAnimationFrame(focusInput); } }, TAB_INDEX_PRIORITY_TIMEOUT, true, false); return captureKeyboardListeners({ onTab: captureFirstTab }); }, [focusInput]); useEffect(() => { const input = inputRef.current!; function suppressFocus() { input.blur(); } if (shouldSuppressFocus) { input.addEventListener('focus', suppressFocus); } return () => { input.removeEventListener('focus', suppressFocus); }; }, [shouldSuppressFocus]); const className = buildClassName( 'form-control custom-scroll', html.length > 0 && 'touched', shouldSuppressFocus && 'focus-disabled', ); return (
{captionLimit && (
{captionLimit}
)}
{!forcedPlaceholder && {placeholder}} {forcedPlaceholder && {renderText(forcedPlaceholder!)}}
); }; export default memo(withGlobal( (global, { chatId, threadId }: OwnProps): StateProps => { const { messageSendKeyCombo } = global.settings.byKey; return { messageSendKeyCombo, replyingToId: chatId && threadId ? selectReplyingToId(global, chatId, threadId) : undefined, isSelectModeActive: selectIsInSelectMode(global), }; }, )(MessageInput));