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 (