import { ErrorInfo, Message } from '@ably/chat'; import { useMessages, useTyping } from '@ably/chat/react'; import React, { ChangeEvent, KeyboardEvent, useCallback, useRef, useState } from 'react'; import { Button } from '../atoms/button.tsx'; import { Icon } from '../atoms/icon.tsx'; import { TextInput } from '../atoms/text-input.tsx'; import { EmojiPicker } from './emoji-picker.tsx'; /** * Props for the MessageInput component */ export interface MessageInputProps { /** * Callback function triggered when a message is sent. * Receives the built message as a parameter. This is useful for providing * an optimistic UI update or for handling the message in a parent component. * * The input field is automatically cleared after sending. * Typing indicators are stopped when a message is sent. * * @param message - The newly sent message object. * * @example * ```tsx * const handleSentMessage = async (message: Message) => { * // alert('Message sent: ' + message.text); * }; * * * ``` */ onSent?: (message: Message) => void; /** * Placeholder text displayed in the input field when empty. * Provides context about the input's purpose to users. * * @default "Type a message..." * * @example * ```tsx * * ``` */ placeholder?: string; /** * Callback function triggered when sending a message fails. * Provides the error object and the text that failed to send. * If not provided, errors will be logged to console. * * @param error - The error that occurred during message sending * @param text - The text that failed to send * * @example * ```tsx * const handleSendError = (error: Error, text: string) => { * toast.error(`Failed to send message: ${error.message}`); * console.error('Send error:', error); * }; * * * ``` */ onSendError?: (error: ErrorInfo, text: string) => void; /** * Whether to enable typing indicators when the user is typing. * When enabled, triggers typing indicators on keystroke and stops them * when the input is cleared or a message is sent. * * @default true * * @example * ```tsx * // Disable typing indicators for performance in large rooms * * ``` */ enableTyping?: boolean; } /** * MessageInput component provides a comprehensive text input interface for composing and sending chat messages * * Core Features: * - Multi-line text input with automatic height adjustment (max 150px) * - Enter key to send (Shift+Enter for new line) * - Integrated emoji picker with cursor position insertion * - Typing indicators to alert others when composing messages * - Automatic input cleanup and focus management * - Accessible form controls with proper ARIA attributes * - Theme-aware styling (light/dark mode support) * * Typing Indicators: * - Triggered on each keystroke when content is present * - Automatically stopped when input is cleared or message is sent * * Emoji Integration: * - Picker positioned above the emoji button * - Smart cursor position handling for emoji insertion * - Maintains focus and cursor position after emoji selection * - Fallback behavior for browsers without selection API support * * @example * // Basic usage in chat interface * const [messages, setMessages] = useState([]); * * const handleSentMessage = (message) => { * setMessages(prev => [...prev, message]); * }; * * return ( *
* * *
* ); * */ export const MessageInput = ({ onSent, placeholder = 'Type a message...', onSendError, enableTyping = true, }: MessageInputProps) => { const [message, setMessage] = useState(''); const messageRef = useRef(''); const [showEmojiPicker, setShowEmojiPicker] = useState(false); const [emojiPickerPosition, setEmojiPickerPosition] = useState({ top: 0, left: 0 }); const inputRef = useRef(null); const { keystroke, stop } = useTyping(); const { sendMessage } = useMessages(); /** * Handles sending the message, clearing the input, and stopping typing indicators */ const handleSend = useCallback(() => { const trimmedMessage = messageRef.current.trim(); if (trimmedMessage) { sendMessage({ text: trimmedMessage }) .then((sentMessage) => { onSent?.(sentMessage); setMessage(''); messageRef.current = ''; if (enableTyping) { stop().catch((error: unknown) => { console.warn('Stop typing failed:', error); }); } }) .catch((error: unknown) => { if (onSendError) { onSendError(error as ErrorInfo, trimmedMessage); } else { console.error('Failed to send message:', error); } }); } }, [sendMessage, stop, onSent, onSendError, enableTyping]); /** * Handles changes to the input field * Updates the message state and manages typing indicators * * @param e - The input change event */ const handleInputChange = (e: ChangeEvent) => { const newValue = e.target.value; setMessage(newValue); messageRef.current = newValue; if (enableTyping) { // Call keystroke on each keypress when there's content if (newValue.trim()) { keystroke().catch((error: unknown) => { console.warn('Keystroke failed:', error); }); } else { // Stop typing indicator when all text is deleted stop().catch((error: unknown) => { console.warn('Stop typing failed:', error); }); } } }; /** * Handles keyboard events in the input field * Sends the message when Enter is pressed (without Shift) * * @param e - The keyboard event */ const handleKeyPress = (e: KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); } }; /** * Opens the emoji picker and positions it relative to the emoji button */ const handleEmojiButtonClick = () => { // Position emoji picker above the emoji button const button = document.querySelector('[data-emoji-button]'); if (button) { const rect = button.getBoundingClientRect(); const pickerWidth = 200; const pickerHeight = 200; // Position above the button const left = Math.max(10, rect.left - pickerWidth / 2 + rect.width / 2); const top = rect.top - pickerHeight - 10; setEmojiPickerPosition({ top, left }); setShowEmojiPicker(true); } }; /** * Handles emoji selection from the emoji picker * Inserts the emoji at the current cursor position * * @param emoji - The selected emoji character */ const handleEmojiSelect = (emoji: string) => { // Insert emoji at current cursor position or at the end const input = inputRef.current; if (input) { const start = input.selectionStart; const end = input.selectionEnd; const newMessage = message.slice(0, start) + emoji + message.slice(end); setMessage(newMessage); messageRef.current = newMessage; // Keep ref in sync // Trigger keystroke for emoji insertion if (enableTyping) { keystroke().catch((error: unknown) => { console.warn('Keystroke failed:', error); }); } // Focus back on input and set cursor position after emoji setTimeout(() => { input.focus(); const newCursorPosition = start + emoji.length; input.setSelectionRange(newCursorPosition, newCursorPosition); }, 0); } else { // Fallback: append to end const newMessage = message + emoji; setMessage(newMessage); messageRef.current = newMessage; // Keep ref in sync if (enableTyping) { keystroke().catch((error: unknown) => { console.warn('Keystroke failed:', error); }); } } setShowEmojiPicker(false); }; return (
{/* Text Input */} } variant="message" multiline={true} maxHeight="150px" value={message} onChange={handleInputChange} onKeyDown={handleKeyPress} placeholder={placeholder} className="flex-1" aria-label="Message text" /> {/* Emoji Button */}
{/* Emoji Picker */} {showEmojiPicker && ( { setShowEmojiPicker(false); }} onEmojiSelect={handleEmojiSelect} position={emojiPickerPosition} /> )}
); };