Message composer
A message composer holds the message a user is writing: its text, attachments, poll, location and quoted message. It knows whether there is anything to send, and it turns what it holds into a message when the user sends. Every channel and every thread has one.
Example
The user types "Hello @Cleo" in a channel and adds a photo. The channel's composer keeps the text and the mention in its text composer, and the photo and its upload in its attachment manager. When the user presses send:
channel.messageComposer.send()runs the composition middleware, which builds the message twice:localMessage, the copy the message list shows at once, andmessage, the request the server receives.- The composer clears itself, before the request goes out, so anything the user types during the round trip is kept.
- The channel's message operations add the message to the list as
sending, send the request, and replace the message with the server's copy when it answers.
const result = await channel.messageComposer.send();
if (result === "nothing-to-send") {
// The composer kept its content. A command or upload problem was also published
// to client.notifications.
}MessageComposer.send() and MessageComposer.update() don't throw when the request fails. They resolve to one of three results (a composition middleware that throws still rejects them):
'sent': the request succeeded.'nothing-to-send': there was nothing to compose, or a middleware stopped the composition, for example because a command isn't ready or an upload is still running. The composer keeps its content. When a command or a running upload stopped it, the reason has been published as a notification.'failed': the request failed. The composer was already cleared. The message stays in the list asfailed, where the user can retry it, and anapi:message:send:failednotification is published. For an edit, the notification isapi:message:update:failed, and the edited message staysfailed, where the user can retry the edit, as Message operations explains.
With offline support, a send that fails because the device is offline still returns 'failed' and leaves a failed message, and the message is also kept as a pending task, which sends it after the next reconnect.
A poll sent on its own doesn't clear the composer: the text, attachments and quoted message next to it stay, ready to send as the next message. Creating and sending the poll explains it.
The SDK's MessageComposer component sends through useMessageInputContext().sendMessage(). It refuses a message with a link when the user lacks the send-links capability, calls MessageComposer.update() for an edit and MessageComposer.send() for everything else, and puts the typed text back into the input when the result is 'nothing-to-send'. The component reads the composer with useMessageComposer(), which returns the composer of the message being edited, else the thread's, else the channel's. Message composer UI covers the components.
Every method and store of the composer is listed in MessageComposer API. How the message is sent and retried is on Message operations.
Composer kinds
| Composer | Where it comes from | contextType |
Sends to |
|---|---|---|---|
| Channel | channel.messageComposer |
'channel' |
the channel |
| Thread | thread.messageComposer |
'thread' |
the thread, as a reply |
| Edit | built from a message | 'message' |
the message's thread when client.threads holds it, else the channel |
The channel and thread composers are created with their channel or thread and live as long as it does. A thread composer stamps every message with the thread's id as parent_id, and the reply lands in thread.messagePaginator.
An edit composer is built for a single message, so the edit doesn't happen in the channel's or thread's own composer. Editing explains how the SDK uses it, and how to build one yourself.
Each composer has an id, which becomes the id of the message it sends. After a send, the composer takes a new one.
Configuration registered through client.config under messageComposer applies to every kind; there's no per-channel or per-thread key for it. To apply something to one kind only, check composer.contextType in a setup function, as Composer middleware shows.
Parts
A composer is made of parts. Each part keeps its data in a store of its own, and each has a page of its own, linked below.
| Part | Holds |
|---|---|
textComposer |
the text, the cursor, mentions, the active command and the open suggestions |
attachmentManager |
attachments and their uploads |
linkPreviewsManager |
previews for the links in the text |
pollComposer |
the poll being created |
locationComposer |
a location to share |
customDataManager |
data of your own |
| the composer itself | the quoted message, the poll id, "also send in channel" and the edited message |
Subscribe to a part's state to follow it. Getters such as textComposer.text or composer.quotedMessage return the value at the moment you read them, and don't tell you when it changes, so they suit event handlers, not rendering. See Reactive state.
const { attachmentManager } = channel.messageComposer;
const unsubscribe = attachmentManager.state.subscribeWithSelector(
({ attachments }) => ({ attachments }),
({ attachments }) => {
const uploading = attachments.filter(
(attachment) => attachment.localMetadata.uploadState === "uploading",
);
console.log(`${uploading.length} uploads running`);
},
);In a component, useAttachmentManagerState() reads the attachments and the upload counts, and useMessageComposerHasSendableData() says whether there is something to send. For the other parts, use useStateStore on the part's state.
Drafts
A draft keeps what a user wrote in a channel or a thread, on the server, until they come back. There is one draft per channel and one per thread. Drafts are off by default:
client.config.set({ messageComposer: { drafts: { enabled: true } } });With drafts on:
MessageComposer.createDraft()saves the composer's content as the draft. It runs the draft middleware, which skips the save when the composer is empty or nothing changed since the draft was loaded. The SDK doesn't call it for you: call it when the user leaves the screen with the composer, as the example below does.- A channel query that returns a draft loads it into the channel's composer, and a thread created from server data starts with its draft.
MessageComposer.getDraft()loads it on demand. The SDK'sMessageComposercomponent callsMessageComposer.getDraft()for a thread's composer when it shows it. - While the composer's subscriptions run, emptying the composer deletes the draft, with
MessageComposer.deleteDraft(), anddraft.updatedanddraft.deletedevents keep the composer in step with the user's other devices. The SDK runs the subscriptions of the composer it shows.
With offline support, a draft that can't be saved or deleted while the device is offline is kept as a pending task and sent after the next reconnect.
This hook saves the draft when the user leaves a channel screen built with React Navigation. Call it in a component rendered inside <Channel>, so useMessageComposer() finds the composer:
import { useEffect } from "react";
import { useNavigation } from "@react-navigation/native";
import { useMessageComposer } from "stream-chat-react-native";
export const useSaveDraftOnLeave = () => {
const navigation = useNavigation();
const messageComposer = useMessageComposer();
useEffect(
() =>
navigation.addListener("beforeRemove", () => {
void messageComposer.createDraft();
}),
[messageComposer, navigation],
);
};Drafts belong to a channel or a thread, not to a message. So while the composer is editing a message, MessageComposer.createDraft() and MessageComposer.deleteDraft() do nothing: unsaved changes to the edit aren't kept, and the channel's or thread's draft stays as it was. Drafts are also skipped for a channel that doesn't exist on the server yet. See Drafts.
Editing
Editing modifies the composition of an existing message. The message is loaded into a composer, changed there like a new message, and saved on the server with MessageComposer.update().
The message can be loaded into the channel's or thread's composer, or into an edit composer created just for it. The choice decides what happens to text the user hasn't sent yet:
Example
The user is typing a new message, "See you at", when they notice a typo in an earlier message and choose Edit.
- In the channel's composer, the edited message replaces "See you at". After the edit is saved, the composer is empty, and "See you at" is gone.
- In an edit composer, the message opens in a second composer. "See you at" stays in the channel's composer, and the user carries on after saving the edit.
The SDK uses an edit composer. A custom UI can use either.
Editing in the channel's or thread's composer
Load the message into the composer:
const composer = channel.messageComposer;
composer.initState({ composition: message });
// composer.editedMessage is now the message; text, attachments and mentions are loaded
await composer.textComposer.handleChange({
selection: { end: 13, start: 13 },
text: "Fixed a typo.",
});
const result = await composer.update();| To... | Call | What happens |
|---|---|---|
| save the edit | MessageComposer.update() |
Saves the edit on the server and clears the composer, which ends the edit. The new text shows in the list at once. If the request fails, the message keeps the new text, its status becomes 'failed', and error says why. To save the edit again, retry the message or edit it again. With offline support, an edit that fails because the device is offline isn't marked failed: it waits as a pending task and is sent after the next reconnect. |
| stop editing without saving | MessageComposer.clear() |
Empties the composer, which ends the edit. |
| undo the changes | MessageComposer.restore() |
Puts the original message back into the composer, and the edit continues from there. |
Loading a message replaces what the composer held. While a message is being edited, commands can't be used and drafts aren't saved. For an edit of a message the server rejected, one with the type error, the SDK's sendMessage() calls MessageComposer.send() instead, which sends it again as a new message.
Editing in an edit composer
The SDK builds an edit composer when the user chooses Edit on a message, and keeps it in client.messageComposerCache. useMessageComposer() returns that composer until the edit is saved or dropped, and the channel's or thread's own composer keeps what the user was writing. useMessageComposerAPIContext().setEditingState(message) starts an edit the same way, and clearEditingState() drops it.
To build one yourself:
import { MessageComposer } from "stream-chat";
import type { LocalMessage, StreamChat } from "stream-chat";
export const createEditComposer = (client: StreamChat, message: LocalMessage) =>
new MessageComposer({
client,
compositionContext: message,
composition: message,
});The message must carry its cid, which tells the composer which channel it belongs to. When the message is a reply and the client holds its thread (client.threads.get(message.parent_id) returns it), the edit goes through that thread, and the thread's message list shows it. Otherwise it goes through the channel. Saving works the same way, with MessageComposer.update().
Pitfalls
- Saving an edit with
MessageComposer.send().MessageComposer.send()always creates a message. On a composer holding an edit it reuses the message's id, so the server rejects the request as a duplicate. The send operation counts that as a delivered send, soMessageComposer.send()returns'sent', the list shows the edited text, and the server keeps the old one. Checkcomposer.editedMessageand callMessageComposer.update(). - A server update during an edit. A
message.updatedor reaction event for the message being edited reloads the composer from the server's copy, and unsaved changes are lost. - Upload settings on one composer. Every upload in a channel goes through the channel composer's upload settings, including uploads from a thread or edit composer. Register
doUploadRequestandtrackUploadProgressfor all composers throughclient.config, not on a single composer. - Reading getters while rendering. Getters don't notify. Subscribe to the part's
state.
Used in
- Sending, editing and deleting
- Drafts
- Quoted replies
- Attachments and uploads
- Polls
- Location sharing
- Link previews
- Message composer UI, for the components that render a composer