Custom composer
This recipe builds a composer with a layout of its own: the attachment button on the left, the previews and the input in the middle, and the send button on the right. It replaces only the layout. The composer object, sending, uploads, suggestions, commands and drafts keep working as before. Which parts the default composer has and what each one reads is explained in Message composer UI.
Best practices
- Replace
MessageComposerUIand keep renderingMessageComposer.MessageComposerpicks the channel's or the thread's composer, saves the draft when it unmounts, and provides the context the parts need: the textarea, paste handling and voice recording. - Build the layout from the exported parts. Each one reads the composer from
useMessageComposerController(), so the same layout works in a channel and in a thread. - Keep
TextareaComposer. It sends typing events, opens the suggestion list for@and/, shows commands and sends on Enter. - Render
MessageComposerActionsfor the send button. It shows the cooldown timer during slow mode, the record button when the composer is empty, and the stop button while an AI is generating. A send button of your own calls the function fromuseMessageComposerSubmitFn(), which saves an edit or sends a new message. - Hide the attachment button while a command is active, as the default composer does. A command takes the whole composer.
- Test the layout with a quoted message, attachments, a link, a voice recording, a command, in a thread and while editing.
Build the layout
Three defaults aren't exported: the edited-message preview, the "also send in channel" checkbox and the location preview. The layout below works around them:
- While a message is being edited, it renders the default
MessageComposerUI, which shows the message being edited and puts back what the user was writing when the edit is cancelled. - It leaves Location out of the attachment menu, because a location attached from the menu would have no preview here.
- The checkbox for thread replies is the last part of this page.
import type { MessageComposerState, TextComposerState } from "stream-chat";
import {
AttachmentPreviewList,
AttachmentSelector,
AudioRecorder,
CommandChip,
defaultAttachmentSelectorActionSet,
LinkPreviewList,
MessageComposerActions,
MessageComposerUI,
QuotedMessagePreview,
TextareaComposer,
useMessageComposerContext,
useMessageComposerController,
useStateStore,
VoiceRecordingPreviewSlot,
WithDragAndDropUpload,
} from "stream-chat-react";
import "./CustomMessageComposer.css";
const editedMessageSelector = ({ editedMessage }: MessageComposerState) => ({
editedMessage,
});
const commandSelector = ({ command }: TextComposerState) => ({ command });
// Every action of the default menu except Location.
const attachmentActions = defaultAttachmentSelectorActionSet.filter(
(action) => action.type !== "addLocation",
);
export const CustomMessageComposer = () => {
const { recordingController } = useMessageComposerContext();
const messageComposer = useMessageComposerController();
const { editedMessage } = useStateStore(
messageComposer.state,
editedMessageSelector,
);
const { command } = useStateStore(
messageComposer.textComposer.state,
commandSelector,
);
// The default UI shows the message being edited, with a button to cancel the edit.
if (editedMessage) return <MessageComposerUI />;
// While the user records a voice message, the recorder takes the composer's place.
if (recordingController.recordingState) return <AudioRecorder />;
return (
<WithDragAndDropUpload className="custom-composer" component="div">
{!command && (
<AttachmentSelector attachmentSelectorActionSet={attachmentActions} />
)}
<div className="custom-composer__center">
<QuotedMessagePreview />
<VoiceRecordingPreviewSlot />
<AttachmentPreviewList />
<LinkPreviewList />
<div className="custom-composer__input">
{command && <CommandChip command={command} />}
<TextareaComposer />
</div>
</div>
<MessageComposerActions />
</WithDragAndDropUpload>
);
};.custom-composer {
align-items: flex-end;
display: flex;
gap: 8px;
padding: 8px 12px;
}
.custom-composer__center {
display: flex;
flex: 1;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.custom-composer__input {
align-items: center;
display: flex;
gap: 8px;
}Here is what each part does:
WithDragAndDropUploaduploads files dropped on the composer, as the default composer does.AttachmentSelectoropens the menu to upload a file, create a poll or pick a command. The menu is an action set, explained in Actions.- The previews render only when the composer has something for them: a quoted message, a voice recording, attachments or link previews.
CommandChipshows the picked command, such as/giphy, with a button that clears it.MessageComposerActionsshows one control: the cooldown timer, the record button, the stop button or the send button.
The emoji picker isn't in this layout. To add it, render AdditionalMessageComposerActions before MessageComposerActions, as the default composer does. It renders the EmojiPicker override, explained in Emoji.
The parts are imported directly, so overrides registered for them, such as an AttachmentPreviewList of your own, don't apply inside this layout. To keep them, read the parts from useComponentContext() and fall back to the imports.
Register it
Register the layout under the MessageComposerUI key, around the composers it applies to. Declare the overrides once, so WithComponents gets the same object on every render:
import {
Channel,
ChannelHeader,
MessageComposer,
MessageList,
WithComponents,
} from "stream-chat-react";
import { CustomMessageComposer } from "./CustomMessageComposer";
const overrides = { MessageComposerUI: CustomMessageComposer };
export const ChannelWithCustomComposer = () => (
<WithComponents overrides={overrides}>
<Channel channel={channel}>
<ChannelHeader />
<MessageList />
<MessageComposer />
</Channel>
</WithComponents>
);To use the layout in threads too, put WithComponents above ChatView, or around each Thread. Overriding components explains how overrides apply.
Thread replies
In a thread, the default composer shows a checkbox that also posts the reply to the channel. It isn't exported, so a layout of your own adds one. The composer keeps the choice in showReplyInChannel, and threadId is set only on a thread's composer:
import type { MessageComposerState } from "stream-chat";
import { useMessageComposerController, useStateStore } from "stream-chat-react";
const showReplyInChannelSelector = ({
showReplyInChannel,
}: MessageComposerState) => ({ showReplyInChannel });
export const AlsoSendInChannel = () => {
const messageComposer = useMessageComposerController();
const { showReplyInChannel } = useStateStore(
messageComposer.state,
showReplyInChannelSelector,
);
if (!messageComposer.threadId) return null;
return (
<label className="custom-composer__also-send">
<input
checked={showReplyInChannel}
onChange={() => messageComposer.toggleShowReplyInChannel()}
type="checkbox"
/>
Also send in channel
</label>
);
};Render <AlsoSendInChannel /> under the input in CustomMessageComposer. It renders nothing in a channel.