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 @Bob" 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() never throw. They resolve to one of three results:
'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 edit is saved by editing again, not by retrying, as Message operations explains.
To keep the content after a send, set retainCompositionOnSubmit in the composer's configuration: a function that receives the composer and returns true to keep it. By default only a poll message keeps it: the poll goes out, and the text typed beside it stays.
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 by you | '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 one you build for a single message, when the edit shouldn't happen in the channel's or thread's own composer:
import { MessageComposer } from "stream-chat";
const editComposer = new MessageComposer({
client,
compositionContext: message,
composition: message,
});The message must carry its cid, which tells the composer which channel it belongs to. Editing shows the other way, editing in place.
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. 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.
| 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`);
},
);Text
TextComposer.handleChange({ text, selection }) is the input path. It runs the text middleware, which finds mentions and commands and opens suggestions for them, as Suggestions explains. TextComposer.insertText({ text }) inserts at the cursor and goes the same way. TextComposer.setText(text) sets the text without running any middleware.
const { textComposer } = channel.messageComposer;
await textComposer.handleChange({
selection: { end: 5, start: 5 },
text: "Hello",
});Two settings limit the length:
text.maxLengthOnEditcuts the text while the user types.text.maxLengthOnSendstops a longer message:MessageComposer.send()returns'nothing-to-send', and no notification is published. A draft is cut to this length instead.
The channel type's max_message_length caps both. A composer that sets neither takes the server's limit.
While text.publishTypingEvents is on, which is the default, each text change reports a keystroke to the channel, which sends the typing events, and a send stops them. See Typing indicators.
Attachments and uploads
AttachmentManager.uploadFiles(files) adds files to the message and uploads them at once. Each attachment's localMetadata.uploadState tells where its upload is:
pending: added, not uploading yet.uploading: on its way.localMetadata.uploadProgressgoes from 0 to 100 whileattachments.trackUploadProgressis on, which is the default.finished: uploaded, with its URL set.failed: the request failed, and anapi:attachment:upload:failednotification was published.blocked: refused before uploading, because the app's upload settings don't allow its type or size. Avalidation:attachment:upload:blockednotification was published.
attachmentManager.isUploadEnabled says whether a file can be added now. Three things must hold: attachments are enabled, which also needs the channel type's uploads setting; a slot is free under attachments.maxNumberOfFilesPerMessage (10 by default); and the user has the upload-file capability. attachments.fileUploadFilter decides which files are taken. attachments.acceptedFiles lists the accepted file types for a file picker to offer; stream-chat doesn't check it.
To upload to your own storage, register an upload request. It resolves with the file's URL, and it can report progress and stop when the user removes the file. Set customCdn: true when the files never reach Stream, so that Stream's uploads setting and the upload-file capability no longer apply:
import type { UploadRequestFn } from "stream-chat";
const uploadToMyStorage: UploadRequestFn = async (file, options) => {
const body = new FormData();
body.append("file", file as Blob);
const response = await fetch("https://uploads.example.com/files", {
body,
method: "POST",
signal: options?.abortSignal,
});
const { url } = await response.json();
return { file: url };
};
client.config.set({
messageComposer: {
attachments: { customCdn: true, doUploadRequest: uploadToMyStorage },
},
});By default a message can't be sent while one of its uploads is running: MessageComposer.send() publishes a validation:attachment:upload:in-progress warning and returns 'nothing-to-send'. With attachments.pendingUploadsEnabled on, the message appears in the list at once and its request waits for the uploads. If one of them fails, the message fails. See Sending with pending uploads.
Link previews
When the text contains a URL, linkPreviewsManager waits for typing to pause (linkPreviews.debounceURLEnrichmentMs, 1500 ms by default) and loads a preview for it. Each preview has a status: loading, loaded, failed, dismissed or pending. LinkPreviewsManager.dismissPreview(url) removes one from the message.
Loaded previews are sent as attachments of the message, but while one of them is still loading, none is sent. Link previews are on by default and also need the channel type's url_enrichment setting. See Link previews.
Polls
pollComposer holds the poll form: PollComposer.updateFields({ name: "Lunch?" }) changes fields and validates them, and pollComposer.canCreatePoll says whether the form is complete. Creating the poll takes two steps:
const composer = channel.messageComposer;
await composer.createPoll(); // creates the poll on the server and sets composer.pollId
await composer.send(); // sends the message that carries the pollIn a channel, the message carries only the poll. Text and attachments typed beside it stay in the composer. MessageComposer.createPoll() throws when the request fails, after publishing an api:poll:create:failed notification. Polls also need the channel type's polls setting. See Polls.
Location
LocationComposer.setData({ latitude, longitude }) sets a location to share. Add durationMs to share a live location for that long; anything shorter than location.minShareDurationMs (one minute by default) isn't valid. MessageComposer.sendLocation() sends the location as a message of its own. In a thread it does nothing. When the request fails, it publishes an api:location:create:failed notification and throws.
Sharing a location also needs the channel type's shared_locations setting. See Location sharing.
Custom data
Data of your own goes one of two ways:
- Sent with the message. Write it into
message.customwith a composition middleware, and intolocalMessage.customtoo, so the copy in the list shows it before the server answers. Composer middleware has an example. - UI only.
CustomDataManager.setCustomData()keeps data in the composer's state, for example a flag your composer UI reads. It's never sent.
CustomDataManager.setMessageData() adds its fields to the top level of the outgoing message, not under custom, so it doesn't reach message.custom.
customDataManager starts empty when a draft is loaded or a message is edited: what it held isn't restored from them.
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 UI layer calls it when the composer goes away, for example when the user opens another channel.- 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. - While the composer's subscriptions run, emptying the composer deletes the draft, and
draft.updatedanddraft.deletedevents keep the composer in step with the user's other devices.
Drafts are skipped while a message is being edited, and for a channel that doesn't exist on the server yet. See Drafts.
Editing
To edit a message in place, load it into a composer. This is how a channel's or thread's own composer switches to editing:
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();Loading a message replaces what the composer held. MessageComposer.update() saves the edit and clears the composer, which leaves editing. The new text shows in the list at once. If the request fails, the edit stays on the message and the message is marked failed. MessageComposer.clear() leaves editing without saving, and MessageComposer.restore() puts the original message back into the composer.
While a message is being edited, commands can't be used and drafts aren't saved.
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 andMessageComposer.send()returns'failed', while the list already shows the edited text. 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