import type { ChangeEvent, FunctionComponent, KeyboardEvent as ReactKeyboardEvent, Ref } from 'react'; import { forwardRef, useCallback, useEffect, useRef, useState } from 'react'; import { Accept, DropEvent, DropzoneOptions, FileError, FileRejection } from 'react-dropzone'; import { ButtonProps, MenuSearchInputProps, MenuSearchProps, SearchInputProps, TextArea, TextAreaProps, TooltipProps } from '@patternfly/react-core'; import { css } from '@patternfly/react-styles'; // Import Chatbot components import SendButton from './SendButton'; import MicrophoneButton from './MicrophoneButton'; import { AttachButton, AttachButtonProps } from './AttachButton'; import AttachMenu from '../AttachMenu'; import StopButton from './StopButton'; import { ChatbotDisplayMode } from '../Chatbot'; export interface MessageBarWithAttachMenuProps { /** Flag to enable whether attach menu is open */ isAttachMenuOpen: boolean; /** Callback to close attach menu */ setIsAttachMenuOpen: (isOpen: boolean) => void; /** Items in menu */ attachMenuItems: React.ReactNode; /** A callback for when the attachment menu toggle is clicked */ onAttachMenuToggleClick: () => void; /** A callback for when the input value in the menu changes. */ onAttachMenuInputChange?: (value: string) => void; /** Function callback called when user selects item in menu. */ onAttachMenuSelect?: (event?: React.MouseEvent, value?: string | number) => void; /** Placeholder for search input */ attachMenuInputPlaceholder?: string; /** Keys that trigger onOpenChange, defaults to tab and escape. It is highly recommended to include Escape in the array, while Tab may be omitted if the menu contains non-menu items that are focusable. */ onAttachMenuOnOpenChangeKeys?: string[]; /** Callback to change the open state of the menu. Triggered by clicking outside of the menu. */ onAttachMenuOpenChange?: (isOpen: boolean) => void; /** Additional props passed to MenuSearch component in attach menu */ menuSearchProps?: Omit; /** Additional props passed to MenuSearchInput component in attach menu */ menuSearchInputProps?: Omit; /** Additional props passed to SearchInput component in attach menu */ searchInputProps?: SearchInputProps; } export interface MessageBarProps extends Omit { /** Callback to get the value of input message by user */ onSendMessage: (message: string | number) => void; /** Class Name for the MessageBar component */ className?: string; /** Flag to always to show the send button. By default send button is shown when there is a message in the input field */ alwayShowSendButton?: boolean; /** Placeholder for message input */ placeholder?: string; /** Flag to disable/enable the Attach button */ hasAttachButton?: boolean; /** Flag to enable the Microphone button */ hasMicrophoneButton?: boolean; /** Placeholder text when listening */ listeningText?: string; /** Flag to enable the Stop button, used for streaming content */ hasStopButton?: boolean; /** Callback function for when stop button is clicked */ handleStopButton?: (event: React.MouseEvent) => void; /** Callback function for when attach button is used to upload a file */ handleAttach?: (data: File[], event: DropEvent) => void; /** Specifies the file types accepted by the attachment upload component. * Files that don't match the accepted types will be disabled in the file picker. * For example, * allowedFileTypes: { 'application/json': ['.json'], 'text/plain': ['.txt'] } **/ allowedFileTypes?: Accept; /** Minimum file size allowed */ minSize?: number; /** Max file size allowed */ maxSize?: number; /** Max number of files allowed */ maxFiles?: number; /** Whether attachments are disabled */ isAttachmentDisabled?: boolean; /** Callback when file(s) are attached */ onAttach?: (acceptedFiles: T[], fileRejections: FileRejection[], event: DropEvent) => void; /** Callback function for AttachButton when an attachment fails */ onAttachRejected?: (fileRejections: FileRejection[], event: DropEvent) => void; /** Validator for files; see https://react-dropzone.js.org/#!/Custom%20validation for more information */ validator?: (file: T) => FileError | readonly FileError[] | null; /** Additional props passed to react-dropzone */ dropzoneProps?: DropzoneOptions; /** Props to enable a menu that opens when the Attach button is clicked, instead of the attachment window */ attachMenuProps?: MessageBarWithAttachMenuProps; /** Flag to provide manual control over whether send button is disabled */ isSendButtonDisabled?: boolean; /** Prop to allow passage of additional props to buttons */ buttonProps?: { attach?: AttachButtonProps & { props?: ButtonProps }; stop?: { tooltipContent?: string; props?: ButtonProps; tooltipProps?: Omit }; send?: { tooltipContent?: string; props?: ButtonProps; tooltipProps?: Omit }; microphone?: { tooltipContent?: { active?: string; inactive?: string }; language?: string; props?: ButtonProps; tooltipProps?: Omit; }; }; /** A callback for when the text area value changes. */ onChange?: (event: React.ChangeEvent, value: string | number) => void; /** Display mode of chatbot, if you want to message bar to resize when the display mode changes */ displayMode?: ChatbotDisplayMode; /** Whether message bar is compact */ isCompact?: boolean; /** Ref applied to message bar textarea, for use with focus or other custom behaviors */ innerRef?: React.Ref; /** Sets background color to primary */ isPrimary?: boolean; /** @beta Flag indicating whether the message bar has an AI indicator border. */ hasAiIndicator?: boolean; /** @beta Flag indicating whether the chatbot is thinking in response to a query, adding an animation to the message bar. */ isThinking?: boolean; } export const MessageBarBase: FunctionComponent = ({ onSendMessage, className, alwayShowSendButton, placeholder = 'Send a message...', hasAttachButton = true, hasMicrophoneButton, listeningText = 'Listening', handleAttach, attachMenuProps, isSendButtonDisabled, handleStopButton, hasStopButton, buttonProps, onChange, displayMode, value, isCompact = false, allowedFileTypes, minSize, maxSize, maxFiles, isAttachmentDisabled, onAttach, onAttachRejected, validator, dropzoneProps, innerRef, isPrimary, hasAiIndicator, isThinking, ...props }: MessageBarProps) => { // Text Input // -------------------------------------------------------------------------- const [message, setMessage] = useState(value ?? ''); const [isListeningMessage, setIsListeningMessage] = useState(false); const [hasSentMessage, setHasSentMessage] = useState(false); const [isComposing, setIsComposing] = useState(false); const [isMultiline, setIsMultiline] = useState(false); const inputRef = useRef(null); const textareaRef = (innerRef as React.RefObject) ?? inputRef; const attachButtonRef = useRef(null); const topMargin = '1rem'; const setInitialLineHeight = (field: HTMLTextAreaElement) => { field.style.setProperty('line-height', '1rem'); const parent = field.parentElement; if (parent) { parent.style.setProperty('margin-top', topMargin); parent.style.setProperty('margin-bottom', `0rem`); parent.style.setProperty('height', 'inherit'); const grandparent = parent.parentElement; if (grandparent) { grandparent.style.setProperty('flex-basis', 'auto'); } } }; const setAutoHeight = (field: HTMLTextAreaElement) => { const parent = field.parentElement; if (parent) { parent.style.setProperty('height', 'inherit'); const computed = window.getComputedStyle(field); // Calculate the height const height = parseInt(computed.getPropertyValue('border-top-width')) + parseInt(computed.getPropertyValue('padding-top')) + field.scrollHeight + parseInt(computed.getPropertyValue('padding-bottom')) + parseInt(computed.getPropertyValue('border-bottom-width')); parent.style.setProperty('height', `${height}px`); if (height > 32 || window.innerWidth <= 507) { parent.style.setProperty('margin-bottom', topMargin); parent.style.setProperty('margin-top', topMargin); } } }; const textIsLongerThan2Lines = (field: HTMLTextAreaElement) => { const lineHeight = parseFloat(window.getComputedStyle(field).lineHeight); const lines = field.scrollHeight / lineHeight; return lines > 2; }; const checkIfMultiline = useCallback( (field: HTMLTextAreaElement) => { const parent = field.parentElement; const grandparent = parent?.parentElement; if (grandparent) { const containerHeight = grandparent.offsetHeight; const threshold = isCompact ? 56 : 70; setIsMultiline(containerHeight > threshold); } }, [isCompact] ); const setAutoWidth = useCallback((field: HTMLTextAreaElement) => { const parent = field.parentElement; if (parent) { const grandparent = parent.parentElement; if (textIsLongerThan2Lines(field) && grandparent) { grandparent.style.setProperty('flex-basis', `100%`); } } }, []); const handleNewLine = (field: HTMLTextAreaElement) => { const parent = field.parentElement; if (parent) { parent.style.setProperty('margin-bottom', topMargin); parent.style.setProperty('margin-top', topMargin); } }; useEffect(() => { const field = textareaRef.current; if (field) { if (field.value === '') { if (window.innerWidth > 507) { setInitialLineHeight(field); } } else { setAutoHeight(field); setAutoWidth(field); } } const resetHeight = () => { if (field) { if (field.value === '') { if (window.innerWidth > 507) { setInitialLineHeight(field); } } else { setAutoHeight(field); setAutoWidth(field); } } }; window.addEventListener('resize', resetHeight); return () => { window.removeEventListener('resize', resetHeight); }; }, [setAutoWidth]); useEffect(() => { const field = textareaRef.current; if (field) { if (field.value === '') { setInitialLineHeight(field); setIsMultiline(false); } else { setAutoHeight(field); setAutoWidth(field); checkIfMultiline(field); } } }, [displayMode, message, setAutoWidth, checkIfMultiline]); useEffect(() => { const field = textareaRef.current; if (field) { setInitialLineHeight(field); setHasSentMessage(false); } }, [hasSentMessage]); const handleChange = useCallback( (event) => { onChange && onChange(event, event.target.value); if (textareaRef.current) { if (event.target.value === '') { setInitialLineHeight(textareaRef.current); setIsMultiline(false); } else { setAutoHeight(textareaRef.current); checkIfMultiline(textareaRef.current); } } setMessage(event.target.value); }, [onChange, checkIfMultiline] ); // Handle sending message const handleSend = useCallback( (newMessage: string | number) => { onSendMessage(newMessage); setHasSentMessage(true); setMessage(''); }, [onSendMessage] ); const handleKeyDown = useCallback( (event: ReactKeyboardEvent) => { // Japanese and other languages may use IME for character input. // In these cases, enter is used to select the final input, so we need to check for composition end instead. // See more info at https://www.stum.de/2016/06/24/handling-ime-events-in-javascript/ // Chrome, Edge, and Firefox seem to work well with just the compose event. // Safari is a little bit special. We need to handle 229 as well in this case. const nativeEvent = event.nativeEvent as KeyboardEvent; const isCompositionKey = nativeEvent.which === 229; const isCurrentlyComposing = isComposing || isCompositionKey; if (event.key === 'Enter' && !isCurrentlyComposing && !event.shiftKey) { event.preventDefault(); if (!isSendButtonDisabled && !hasStopButton) { handleSend(message); } } if (event.key === 'Enter' && !isCurrentlyComposing && event.shiftKey) { if (textareaRef.current) { handleNewLine(textareaRef.current); } } }, [isSendButtonDisabled, hasStopButton, handleSend, message, isComposing] ); const handleCompositionStart = useCallback(() => { setIsComposing(true); }, []); const handleCompositionEnd = useCallback(() => { setIsComposing(false); }, []); const handleAttachMenuToggle = () => { attachMenuProps?.setIsAttachMenuOpen && attachMenuProps?.setIsAttachMenuOpen(!attachMenuProps?.isAttachMenuOpen); attachMenuProps?.onAttachMenuToggleClick(); }; const handleSpeechRecognition = (message) => { setMessage(message); onChange && onChange({} as ChangeEvent, message); }; const renderButtons = () => { if (hasStopButton && handleStopButton) { return ( ); } return ( <> {attachMenuProps && ( )} {!attachMenuProps && hasAttachButton && ( )} {hasMicrophoneButton && ( )} {(alwayShowSendButton || message) && ( handleSend(message)} isDisabled={isSendButtonDisabled} tooltipContent={buttonProps?.send?.tooltipContent} isCompact={isCompact} tooltipProps={buttonProps?.send?.tooltipProps} {...buttonProps?.send?.props} /> )} ); }; const messageBarContents = ( <>