Message operations

Message operations cover the step between the composer and the server: how a message's request is sent, shown in the list, and settled. They're the interface for sending, retrying, editing and deleting messages, and for adding or removing reactions.

This page explains how to:

  • follow a message's request: whether it's still sending, saved by the server, or failed;
  • rely on optimistic updates: what the list shows at once for each operation, and what's undone when its request fails;
  • call the operations the composer doesn't cover, such as retrying a failed send or deleting a message;
  • handle the operations' requests your own way, by registering request handlers, for example to send them through your backend.
Info:

Example

  1. The user sends "Hello". The message appears in the list at once, as sending, due to optimistic update.
  2. The request fails. The message stays in the list, marked failed, with the server's error.
  3. The user retries. The message is marked as sending again, and the same request goes out.
  4. The server answers. Its copy replaces the local one, and the message is marked as received.
const retryMessage = async (messageId: string) => {
  const localMessage = channel.messagePaginator.getItem(messageId);
  if (localMessage?.status !== "failed") return;

  try {
    await channel.messageOperations.retry({ localMessage });
  } catch {
    // The message is marked failed again, ready for another retry.
  }
};

Unlike the composer's MessageComposer.send(), the operations reject when the request fails. The list is already settled by then, so the rejection only tells the caller.

Operations

Every channel has its operations interface in channel.messageOperations, and every thread in thread.messageOperations. The MessageOperations class itself isn't exported from stream-chat. A thread's operations send to the thread's channel, with the thread's id as parent_id, and the reply lands in thread.messagePaginator. The composer's MessageComposer.send() and MessageComposer.update() use them underneath.

Operation Takes
MessageOperations.send({ localMessage, message?, options? }) The message to show, and optionally the request body and send options.
MessageOperations.retry({ localMessage, message?, options? }) A failed message. Without message, it resends the request that failed.
MessageOperations.update({ localMessage, options? }) The edited message. options takes skip_enrich_url and skip_push.
MessageOperations.delete({ localMessage, options? }) The message. options takes hard and delete_for_me.
MessageOperations.addReaction({ messageId, reaction, options? }) The message id and the reaction, such as { type: "like" }.
MessageOperations.deleteReaction({ messageId, type }) The message id and the reaction type.

When a change should show in the message list, use the operations, not raw API calls such as Channel.sendMessage() or client.updateMessage(). A raw call only sends the request: in a watched channel, the list changes when the server's event arrives, and a failed request isn't shown in it. Raw API calls lists them.

Message statuses

A message on its way has one of three statuses in message.status:

message.status Means What your UI can do
sending The message is in the list, and the request is on its way. Show it as pending.
received The server's copy is in the list. Show it as sent. Whether it was delivered and read comes from channel.messageReceiptsTracker: see Who received and read a message.
failed The request failed, and message.error says why. Offer a retry for a send, with MessageOperations.retry({ localMessage }). A failed edit is saved by editing again, with MessageOperations.update(), not by retrying.

These statuses describe the request, and don't match the statuses a UI shows on the user's own messages: sending, sent, delivered and read. A UI shows a received message as sent until another member has it, and takes delivered and read from the other members' read state, as Who received and read a message explains.

A failed send keeps its request for five minutes, for up to 100 messages, so a retry sends exactly what failed. After that, a retry rebuilds the request from the message. Both limits can be changed under messageOperations in Configuration.

If the server rejects a send because the message already exists, the earlier attempt did arrive. The message is marked received rather than failed, although the call still rejects.

Optimistic updates

The operations change the list before the request goes out:

Operation Shown at once When the request fails
MessageOperations.send() and MessageOperations.retry() the message, as sending marked failed
MessageOperations.update() the new text, marked as edited kept, and marked failed
MessageOperations.delete() the message as deleted, or removed for a hard delete undone
MessageOperations.addReaction() and MessageOperations.deleteReaction() the reaction appears or disappears undone

When the server answers, its copy replaces the local one. For a send or an edit, that happens only if nothing newer arrived in the meantime, such as a message.updated event.

Conversation actions, such as marking a channel read or archiving it, work the other way: their effect appears when the server's event arrives. Every action is listed on What's optimistic and what isn't.

Customizing requests

To change how a request is made, register a request handler in client.config. The operation then calls your handler instead of making the request itself. It passes the handler the default request, the one it would make without a handler, as the second argument. Call it to send the usual way, with the parameters as given or changed, for example with skip_push: true in options. Leave it out to send somewhere else:

import type { OperationRequestFn } from "stream-chat";

const sendMessageRequest: OperationRequestFn<"send"> = async (
  params,
  defaultRequest,
) => {
  if (!params.localMessage.cid.startsWith("support:")) {
    return defaultRequest(params);
  }

  const response = await fetch("https://api.example.com/support/messages", {
    body: JSON.stringify(params.message),
    headers: { "Content-Type": "application/json" },
    method: "POST",
  });
  const { message } = await response.json();

  return { message };
};

client.config.set({ channel: { requestHandlers: { sendMessageRequest } } });

Here, messages in support channels go through the app's backend, which sends them to Stream and returns the server's copy. Every other channel sends the usual way.

The channel key in client.config names the kind of object the handlers belong to, not one channel: the handlers apply to every channel, the ones that exist and the ones created later. A channel has no settings of its own (see Settings for one object), so to treat channels differently, branch inside the handler, for example on params.localMessage.cid. There's no request function for a single call: every call goes through the registered handler.

The optimistic update happens before the handler runs, and settling the list happens after it, whatever the handler does.

Request handlers

Handler key in config Runs on Receives Resolves to
sendMessageRequest MessageOperations.send(), and MessageOperations.retry() when there's no retry handler { localMessage, message, options } { message }
retrySendMessageRequest MessageOperations.retry() { localMessage, message, options } { message }
updateMessageRequest MessageOperations.update() { localMessage, options } { message }
deleteMessageRequest MessageOperations.delete() { localMessage, options } { message }

In send and retry, message is the request body and localMessage the copy in the list. The message the handler resolves with is the server's copy, and it replaces the local one.

Marking a channel or a thread read has a request handler too, markReadRequest. Read state and unread counts explains it.

Threads use their channel's handlers

A thread reply is sent, retried, edited and deleted through the handlers registered under channel. There are no message handlers under thread.

Pitfalls

  • Resolve with the server's message. A send handler that resolves without a message leaves the local copy as sending until the server's message.new event replaces it.
  • Retrying a failed edit. MessageOperations.retry() sends the edited message as a new one. The server rejects it because the message exists, and the list then shows the edit as received, although the server kept the old text. Load the message into a composer and call MessageComposer.update() again.

Used in