import { Components, hooks } from 'botframework-webchat'; import { usePostVoiceActivity, useShouldShowMicrophoneButton } from 'botframework-webchat/internal.js'; import cx from 'classnames'; import React, { memo, ReactNode, useCallback, useMemo, useRef, useState, type ClipboardEventHandler, type FormEventHandler, type MouseEventHandler } from 'react'; import { useRefFrom } from 'use-ref-from'; import { useStyles, useVariantClassName } from '../../styles'; import testIds from '../../testIds'; import { DropZone } from '../dropZone'; import { FluentIcon } from '../icon'; import { SuggestedActions } from '../suggestedActions'; import { TelephoneKeypadSurrogate, useTelephoneKeypadShown, type DTMF } from '../telephoneKeypad'; import AddAttachmentButton from './AddAttachmentButton'; import ErrorMessage from './ErrorMessage'; import useSpeechStateMessage from './private/useSpeechStateMessage'; import useSubmitError from './private/useSubmitError'; import useTranscriptNavigation from './private/useTranscriptNavigation'; import useUniqueId from './private/useUniqueId'; import styles from './SendBox.module.css'; import TelephoneKeypadToolbarButton from './TelephoneKeypadToolbarButton'; import { Toolbar, ToolbarButton, ToolbarSeparator } from './Toolbar'; import MicrophoneToolbarButton from './MicrophoneToolbarButton'; const { useFocus, useLocalizer, useMakeThumbnail, useRegisterFocusSendBox, useSendBoxAttachments, useSendBoxValue, useSendMessage, useStyleOptions, useUIState, useVoiceState } = hooks; const { AttachmentBar, TextArea } = Components; type Props = Readonly<{ className?: string | undefined; completion?: ReactNode | undefined; isPrimary?: boolean | undefined; placeholder?: string | undefined; }>; function SendBox(props: Props) { const [{ disableFileUpload, hideTelephoneKeypadButton, maxMessageLength }] = useStyleOptions(); const [attachments, setAttachments] = useSendBoxAttachments(); const [globalMessage, setGlobalMessage] = useSendBoxValue(); const [localMessage, setLocalMessage] = useState(''); const [telephoneKeypadShown] = useTelephoneKeypadShown(); const [uiState] = useUIState(); const [voiceState] = useVoiceState(); const classNames = useStyles(styles); const variantClassName = useVariantClassName(styles); const errorMessageId = useUniqueId('sendbox__error-message-id'); const inputRef = useRef(null); const localize = useLocalizer(); const makeThumbnail = useMakeThumbnail(); const postVoiceActivity = usePostVoiceActivity(); const sendMessage = useSendMessage(); const setFocus = useFocus(); const showMicrophoneButton = useShouldShowMicrophoneButton(); const speechStateMessage = useSpeechStateMessage(); const message = props.isPrimary ? globalMessage : localMessage; const recording = voiceState !== 'idle'; const setMessage = props.isPrimary ? setGlobalMessage : setLocalMessage; const isBlueprint = uiState === 'blueprint'; const [errorMessage, commitLatestError] = useSubmitError({ message, attachments }); const isMessageLengthExceeded = !!maxMessageLength && message.length > maxMessageLength; const shouldShowMessageLength = useMemo( () => !isBlueprint && !telephoneKeypadShown && !!maxMessageLength && isFinite(maxMessageLength) && !showMicrophoneButton, [isBlueprint, telephoneKeypadShown, maxMessageLength, showMicrophoneButton] ); const shouldShowTelephoneKeypad = !isBlueprint && telephoneKeypadShown; useRegisterFocusSendBox( useCallback( ({ noKeyboard }) => { const { current } = inputRef; // Setting `inputMode` to `none` temporarily to suppress soft keyboard in iOS. // We will revert the change once the end-user tap on the send box. // This code path is only triggered when the user press "send" button to send the message, instead of pressing ENTER key. noKeyboard && current?.setAttribute('inputmode', 'none'); current?.focus(); }, [inputRef] ) ); const attachmentsRef = useRefFrom(attachments); const messageRef = useRefFrom(message); const handleSendBoxClick = useCallback( event => { if ('tabIndex' in event.target && typeof event.target.tabIndex === 'number' && event.target.tabIndex >= 0) { return; } setFocus('sendBox'); }, [setFocus] ); const handleMessageChange: React.FormEventHandler = useCallback( event => setMessage(event.currentTarget.value), [setMessage] ); const handleAddFiles = useCallback( async (inputFiles: readonly File[]) => { const newAttachments = Object.freeze( await Promise.all( inputFiles.map(file => makeThumbnail(file).then(thumbnailURL => Object.freeze({ blob: file, ...(thumbnailURL && { thumbnailURL }) }) ) ) ) ); setAttachments(attachmentsRef.current.concat(newAttachments)); }, [attachmentsRef, makeThumbnail, setAttachments] ); const handleClick = useCallback(({ currentTarget }) => currentTarget.removeAttribute('inputmode'), []); const handlePaste = useCallback( event => { if (disableFileUpload || uiState === 'disabled') { return; } const { files } = event.clipboardData; if (files.length) { event.preventDefault(); handleAddFiles(Object.freeze(Array.from(files))); } }, [disableFileUpload, handleAddFiles, uiState] ); const handleFormSubmit: FormEventHandler = useCallback( event => { event.preventDefault(); const error = commitLatestError(); if (error !== 'empty' && !isMessageLengthExceeded) { sendMessage(messageRef.current, undefined, { attachments: attachmentsRef.current }); setMessage(''); setAttachments([]); } setFocus('sendBox'); }, [ commitLatestError, isMessageLengthExceeded, setFocus, sendMessage, setMessage, messageRef, attachmentsRef, setAttachments ] ); const handleTelephoneKeypadButtonClick = useCallback( (dtmf: DTMF) => { if (recording) { postVoiceActivity({ name: 'media.end', type: 'event', value: { key: dtmf } } as any); } else { // TODO: We need more official way of sending DTMF. sendMessage(`/DTMFKey ${dtmf}`); } }, [postVoiceActivity, recording, sendMessage] ); const handleTranscriptNavigation = useTranscriptNavigation(); const aria = { 'aria-invalid': 'false' as const, ...(errorMessage && { 'aria-describedby': errorMessageId, 'aria-errormessage': errorMessageId, 'aria-invalid': 'true' as const }) }; return (