import { ErrorInfo, Message, MessageReactionType } from '@ably/chat';
import { useMessages, usePresence } from '@ably/chat/react';
import { clsx } from 'clsx';
import React, { useCallback } from 'react';
import { useMessageWindow } from '../../hooks/use-message-window.tsx';
import { ChatMessageList } from './chat-message-list.tsx';
import { ChatWindowFooter } from './chat-window-footer.tsx';
import { ChatWindowHeader } from './chat-window-header.tsx';
import { MessageInput } from './message-input.tsx';
/**
* Props for the ChatWindow component
*/
export interface ChatWindowProps {
/**
* Unique identifier for the chat room.
* Used for room-specific settings lookup and display customization.
* Must be a valid room name as defined by your chat service.
*/
roomName: string;
/**
* Optional custom content for the header area of the chat window.
* Typically contains room information, participant counts, settings buttons,
* or other room-specific controls and metadata display.
*
* Content is rendered within the ChatWindowHeader component and inherits
* the header's styling and layout constraints.
*
* @example
* customHeaderContent={
*
* }
*/
customHeaderContent?: React.ReactNode;
/**
* Optional custom content for the footer area of the chat window.
* Typically contains additional input controls like reaction pickers,
* file upload buttons, formatting tools, or other message composition aids.
*
* Content is rendered alongside the MessageInput within the ChatWindowFooter
* and should be designed to complement the primary input functionality.
*
* @example
* customFooterContent={
*
*
*
*
*
* }
*/
customFooterContent?: React.ReactNode;
/**
* Whether to show typing indicators in the chat window.
* When enabled, shows indicators when other users are typing.
*
* @default true
*
* @example
* // Disable typing indicators for performance in large rooms
* enableTypingIndicators={false}
*/
enableTypingIndicators?: boolean;
/**
* When `true` (default) the user is put into the room's presence set
* immediately. Set to `false` if you need to
* join later (e.g. after showing a “Join chat” button).
*
* @default true
*/
autoEnterPresence?: boolean;
/**
* Controls the window size for rendering messages in UI. A larger window size will
* produce a smoother scrolling experience, but at the cost of increased memory usage.
* Too high a value may lead to significant performance issues.
*
* @default 200
* windowSize={200}
*/
windowSize?: number;
/**
* Additional CSS class names to apply to the root container.
* Useful for custom styling, layout adjustments, theme variations,
* or integration with external design systems.
*
* Applied to the outermost div element and combined with default styling.
*/
className?: string;
/**
* Custom error handling configuration for chat operations.
* Provides hooks for handling specific error scenarios instead of default console logging.
* All handlers are optional and will fall back to console.error if not provided.
*
* @example
* ```tsx
* const onError = {
* onMessageUpdateError: (error, message) => {
* toast.error(`Failed to edit message: ${error.message}`);
* console.error('Edit error:', error);
* },
* onMessageDeleteError: (error, message) => {
* toast.error(`Failed to delete message: ${error.message}`);
* },
* onSendReactionError: (error, message, emoji) => {
* toast.error(`Failed to add ${emoji} reaction: ${error.message}`);
* },
* onMessageSendError: (error, text) => {
* toast.error(`Failed to send message: ${error.message}`);
* }
* };
*
*
* ```
*/
onError?: {
/**
* Called when message editing fails.
* Provides the error object and the message that failed to edit.
*
* @param error - The error that occurred during message editing
* @param message - The message that failed to edit
*/
onMessageUpdateError?: (error: ErrorInfo, message: Message) => void;
/**
* Called when message deletion fails.
* Provides the error object and the message that failed to delete.
*
* @param error - The error that occurred during message deletion
* @param message - The message that failed to delete
*/
onMessageDeleteError?: (error: ErrorInfo, message: Message) => void;
/**
* Called when adding a reaction to a message fails.
* Provides the error object, the message, and the emoji that failed to add.
*
* @param error - The error that occurred during reaction addition
* @param message - The message that failed to receive the reaction
* @param emoji - The emoji that failed to be added as a reaction
*/
onSendReactionError?: (error: ErrorInfo, message: Message, emoji: string) => void;
/**
* Called when removing a reaction from a message fails.
* Provides the error object, the message, and the emoji that failed to remove.
*
* @param error - The error that occurred during reaction removal
* @param message - The message that failed to have the reaction removed
* @param emoji - The emoji that failed to be removed as a reaction
*/
onRemoveReactionError?: (error: ErrorInfo, message: Message, emoji: string) => void;
/**
* Called when sending a message fails.
* Provides the error object and the text that failed to send.
*
* @param error - The error that occurred during message sending
* @param text - The text that failed to send
*/
onMessageSendError?: (error: ErrorInfo, text: string) => void;
};
}
/**
* ChatWindow component provides the main chat interface for a room.
*
* Features:
* - Message display with history loading
* - Message editing, deletion, and reactions
* - Typing indicators and presence
* - Custom header and footer content
* - Discontinuity recovery on reconnection
* - Active chat window management to control which messages are rendered in the UI.
* - History loading with infinite scroll support
* - Custom error handling for all chat operations
*
* The enableTypingIndicators prop controls both the display of typing indicators in the
* message list and whether the message input triggers typing events on keystroke.
*
* @example
* // Basic usage
*
*
*
*
* @example
* // With custom header and footer
*
* }
* customFooterContent={}
* />
*
*
* @example
* // With typing indicators disabled
*
*
*
*
* @example
* // With custom error handling
* const onError = {
* onMessageUpdateError: (error, message) => {
* toast.error(`Failed to edit message: ${error.message}`);
* console.error('Edit failed:', error);
* },
* onMessageDeleteError: (error, message) => {
* toast.error(`Failed to delete message: ${error.message}`);
* },
* onSendReactionError: (error, message, emoji) => {
* toast.error(`Failed to add ${emoji} reaction: ${error.message}`);
* },
* onRemoveReactionError: (error, message, emoji) => {
* toast.error(`Failed to remove ${emoji} reaction: ${error.message}`);
* },
* onMessageSendError: (error, text) => {
* toast.error(`Failed to send message: ${error.message}`);
* }
* };
*
*
*
*
*/
export const ChatWindow = ({
roomName,
customHeaderContent,
customFooterContent,
windowSize = 200,
enableTypingIndicators = true,
autoEnterPresence = true,
className,
onError,
}: ChatWindowProps) => {
// Initialize presence for the room,
usePresence({ autoEnterLeave: autoEnterPresence });
const { deleteMessage, updateMessage, sendReaction, deleteReaction } = useMessages();
const {
activeMessages,
updateMessages,
showLatestMessages,
showMessagesAroundSerial,
loadMoreHistory,
hasMoreHistory,
loading,
} = useMessageWindow({ windowSize });
const handleRESTMessageUpdate = useCallback(
(updated: Message) => {
updateMessages([updated]);
},
[updateMessages]
);
const handleMessageUpdate = useCallback(
(msg: Message, newText: string) => {
const updated = msg.copy({ text: newText, metadata: msg.metadata, headers: msg.headers });
updateMessage(msg.serial, updated)
.then(handleRESTMessageUpdate)
.catch((error: unknown) => {
if (onError?.onMessageUpdateError) {
onError.onMessageUpdateError(error as ErrorInfo, msg);
} else {
console.error('Failed to update message:', error);
}
});
},
[updateMessage, handleRESTMessageUpdate, onError]
);
const handleMessageDelete = useCallback(
(msg: Message) => {
deleteMessage(msg, { description: 'deleted by user' })
.then(handleRESTMessageUpdate)
.catch((error: unknown) => {
if (onError?.onMessageDeleteError) {
onError.onMessageDeleteError(error as ErrorInfo, msg);
} else {
console.error('Failed to delete message:', error);
}
});
},
[deleteMessage, handleRESTMessageUpdate, onError]
);
const handleReactionAdd = useCallback(
(msg: Message, emoji: string) => {
sendReaction(msg, { type: MessageReactionType.Distinct, name: emoji }).catch(
(error: unknown) => {
if (onError?.onSendReactionError) {
onError.onSendReactionError(error as ErrorInfo, msg, emoji);
} else {
console.error('Failed to add reaction:', error);
}
}
);
},
[sendReaction, onError]
);
const handleReactionRemove = useCallback(
(msg: Message, emoji: string) => {
deleteReaction(msg, { type: MessageReactionType.Distinct, name: emoji }).catch(
(error: unknown) => {
if (onError?.onRemoveReactionError) {
onError.onRemoveReactionError(error as ErrorInfo, msg, emoji);
} else {
console.error('Failed to remove reaction:', error);
}
}
);
},
[deleteReaction, onError]
);
return (
{/* Header */}
{customHeaderContent &&
{customHeaderContent}}
{/* Messages */}
{
void loadMoreHistory();
}}
hasMoreHistory={hasMoreHistory}
enableTypingIndicators={enableTypingIndicators}
onEdit={handleMessageUpdate}
onDelete={handleMessageDelete}
onReactionAdd={handleReactionAdd}
onReactionRemove={handleReactionRemove}
onMessageInView={showMessagesAroundSerial}
onViewLatest={showLatestMessages}
>
{/* Footer */}
{
updateMessages([msg]);
}}
placeholder={`Message ${roomName}...`}
aria-label={`Send message to ${roomName}`}
onSendError={onError?.onMessageSendError}
enableTyping={enableTypingIndicators}
/>
{customFooterContent}
);
};
ChatWindow.displayName = 'ChatWindow';