Info:
This is release candidate documentation for Stream Chat React Native SDK v10. For the latest stable version, see the latest version (v9).

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.

Info:

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:

  1. channel.messageComposer.send() runs the composition middleware, which builds the message twice: localMessage, the copy the message list shows at once, and message, the request the server receives.
  2. The composer clears itself, before the request goes out, so anything the user types during the round trip is kept.
  3. 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 as failed, where the user can retry it, and an api:message:send:failed notification is published. For an edit, the notification is api:message:update:failed, and the edit is saved by editing again, not by retrying, 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.

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.

The SDK's MessageComposer component sends with MessageComposer.send(), and saves an edit with MessageComposer.update(). It 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. The SDK builds one when the user edits a message, and keeps it in client.messageComposerCache. 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. 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`);
  },
);

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.

Text

TextComposer.handleChange({ text, selection }) is the input path, and the SDK's text input calls it on every change. 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.maxLengthOnEdit cuts the text while the user types.
  • text.maxLengthOnSend stops 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. Who is typing is in channel.state.typing. See Typing indicators.

Attachments and uploads

AttachmentManager.uploadFiles(files) adds files to the message and uploads them at once. In React Native, a file is a FileReference: an object with the file's uri, name, type (its MIME type) and size, as the SDK's pickers return it. Each attachment's localMetadata.uploadState tells where its upload is:

  • pending: added, not uploading yet.
  • uploading: on its way. localMetadata.uploadProgress goes from 0 to 100 while attachments.trackUploadProgress is on, which is the default.
  • finished: uploaded, with its URL set.
  • failed: the request failed, and an api:attachment:upload:failed notification was published.
  • blocked: refused before uploading, because the Stream app's upload settings refuse it. These are set in the Dashboard: the allowed and blocked file types, and the size limit, 100 MB when none is set. A validation:attachment:upload:blocked notification was published. The SDK's composer then removes the attachment.

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. Neither stream-chat nor the SDK's pickers 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 { isFileReference, StreamChat } from "stream-chat";
import type { UploadRequestFn } from "stream-chat";

const client = StreamChat.getInstance("YOUR_API_KEY");

const uploadToMyStorage: UploadRequestFn = async (file, options) => {
  if (!isFileReference(file)) throw new Error("Expected a file reference");

  const body = new FormData();
  body.append("file", { name: file.name, type: file.type, uri: file.uri });

  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. <Chat enableOfflineSupport> turns pendingUploadsEnabled on, unless your app has set it in client.config. See Sending with pending uploads.

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({ data: { 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 poll

The SDK's poll creation screen does the same. In 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. CustomDataManager.setMessageData() merges its fields into custom of the outgoing message and of localMessage, so the copy in the list shows them before the server answers. A saved draft keeps them under custom too. For data computed while the message is built, write it into message.custom and localMessage.custom with a composition middleware instead. 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 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 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's MessageComposer component calls MessageComposer.getDraft() for a thread's composer when it shows it.
  • While the composer's subscriptions run, emptying the composer deletes the draft, and draft.updated and draft.deleted events 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 are skipped while a message is being edited, and for a channel that doesn't exist on the server yet. See Drafts.

Editing

When the user edits a message, the SDK loads the message into an edit composer, and useMessageComposer() returns that composer until the edit is saved or dropped. The channel's or thread's own composer keeps what the user was writing. Saving the edit of a message the server rejected, with the type error, sends it again as a new message.

To edit a message in place instead, 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. 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. 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 and MessageComposer.send() returns 'failed', while the list already shows the edited text. Check composer.editedMessage and call MessageComposer.update().
  • A server update during an edit. A message.updated or 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 doUploadRequest and trackUploadProgress for all composers through client.config, not on a single composer.
  • Reading getters while rendering. Getters don't notify. Subscribe to the part's state.

Used in