// Copyright 2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import React, { useRef, useCallback, useState } from 'react'; import type { LocalizerType } from '../types/I18N'; import type { EmojiPickDataType } from './emoji/EmojiPicker'; import type { InputApi } from './CompositionInput'; import { CompositionInput } from './CompositionInput'; import { EmojiButton } from './emoji/EmojiButton'; import { hydrateRanges, type DraftBodyRanges, type HydratedBodyRangesType, } from '../types/BodyRange'; import type { ThemeType } from '../types/Util'; import type { Props as EmojiButtonProps } from './emoji/EmojiButton'; import type { PreferredBadgeSelectorType } from '../state/selectors/badges'; import * as grapheme from '../util/grapheme'; import { FunEmojiPicker } from './fun/FunEmojiPicker'; import type { FunEmojiSelection } from './fun/panels/FunPanelEmojis'; import type { EmojiSkinTone } from './fun/data/emojis'; import { FunEmojiPickerButton } from './fun/FunButton'; import { isFunPickerEnabled } from './fun/isFunPickerEnabled'; import type { GetConversationByIdType } from '../state/selectors/conversations'; export type CompositionTextAreaProps = { bodyRanges: HydratedBodyRangesType | null; i18n: LocalizerType; isActive: boolean; isFormattingEnabled: boolean; maxLength?: number; placeholder?: string; whenToShowRemainingCount?: number; onScroll?: (ev: React.UIEvent) => void; onPickEmoji: (e: EmojiPickDataType) => void; onChange: ( messageText: string, draftBodyRanges: HydratedBodyRangesType, caretLocation?: number | undefined ) => void; onEmojiSkinToneDefaultChange: (emojiSkinToneDefault: EmojiSkinTone) => void; onSubmit: ( message: string, draftBodyRanges: DraftBodyRanges, timestamp: number ) => void; onTextTooLong: () => void; ourConversationId: string | undefined; platform: string; getPreferredBadge: PreferredBadgeSelectorType; draftText: string; theme: ThemeType; conversationSelector: GetConversationByIdType; } & Pick; /** * Essentially an HTML textarea but with support for emoji picker and * at-mentions autocomplete. * * Meant for modals that need to collect a message or caption. It is * basically a rectangle input with an emoji selector floating at the top-right */ export function CompositionTextArea({ bodyRanges, draftText, getPreferredBadge, i18n, isActive, isFormattingEnabled, maxLength, onChange, onPickEmoji, onScroll, onEmojiSkinToneDefaultChange, onSubmit, onTextTooLong, ourConversationId, placeholder, platform, recentEmojis, emojiSkinToneDefault, theme, whenToShowRemainingCount = Infinity, conversationSelector, }: CompositionTextAreaProps): JSX.Element { const inputApiRef = useRef(); const [characterCount, setCharacterCount] = useState( grapheme.count(draftText) ); const insertEmoji = useCallback( (e: EmojiPickDataType) => { if (inputApiRef.current) { inputApiRef.current.insertEmoji(e); onPickEmoji(e); } }, [inputApiRef, onPickEmoji] ); const handleSelectEmoji = useCallback( (emojiSelection: FunEmojiSelection) => { const data: EmojiPickDataType = { shortName: emojiSelection.englishShortName, skinTone: emojiSelection.skinTone, }; insertEmoji(data); }, [insertEmoji] ); const focusTextEditInput = useCallback(() => { if (inputApiRef.current) { inputApiRef.current.focus(); } }, [inputApiRef]); const [emojiPickerOpen, setEmojiPickerOpen] = useState(false); const handleEmojiPickerOpenChange = useCallback( (open: boolean) => { setEmojiPickerOpen(open); if (!open) { focusTextEditInput(); } }, [focusTextEditInput] ); const handleChange = useCallback( ({ bodyRanges: updatedBodyRanges, caretLocation, messageText: newValue, }: { bodyRanges: DraftBodyRanges; caretLocation?: number | undefined; messageText: string; }) => { const inputEl = inputApiRef.current; if (!inputEl) { return; } const [newValueSized, newCharacterCount] = grapheme.truncateAndSize( newValue, maxLength ); const hydratedBodyRanges = hydrateRanges(updatedBodyRanges, conversationSelector) ?? []; if (maxLength !== undefined) { // if we had to truncate if (newValueSized.length < newValue.length) { // reset quill to the value before the change that pushed it over the max // and push the cursor to the end // // this is not perfect as it pushes the cursor to the end, even if the user // was modifying text in the middle of the editor // a better solution would be to prevent the change to begin with, but // quill makes this VERY difficult inputEl.setContents(newValueSized, hydratedBodyRanges, true); } } setCharacterCount(newCharacterCount); onChange(newValue, hydratedBodyRanges, caretLocation); }, [maxLength, onChange, conversationSelector] ); return (
{!isFunPickerEnabled() && ( )} {isFunPickerEnabled() && ( )}
{maxLength !== undefined && characterCount >= whenToShowRemainingCount && (
{maxLength - characterCount}
)}
); }