Message operations
Every channel and every thread has messageOperations: the operations that send, retry, edit and delete a message, and add or remove a reaction. Each one changes the message list first, then sends the request, and settles the list when the server answers. The composer's MessageComposer.send() and MessageComposer.update() go through them.
Example
The user sends "Hello" and the request fails:
- The message appears in the list at once, with the status
sending. - The request fails. The message stays in the list, now
failed, with the server's error inmessage.error. - The user retries.
MessageOperations.retry({ localMessage })marks the messagesendingagain and sends the same request. - The server answers. Its copy replaces the local one, with the status
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.
The SDK's message actions call these operations through useMessageOperations(): retrying a failed message, deleting a message, and adding or removing a reaction. They use the open thread's operations for a reply loaded in the thread, and the channel's otherwise. Deleting a message that failed to send removes it from the list without a request, because the server never had it.
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, and a failed edit is sent again as an edit. |
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 Read state, unread and receipts. |
failed |
The request failed, and message.error says why. |
Offer a retry, with MessageOperations.retry({ localMessage }). For a failed send it sends the request again. For a failed edit it sends the edit again, and editing the message again with MessageOperations.update() saves it as well. |
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. The SDK's MessageStatus shows a received message as sent until another member has it, and takes delivered and read from the other members' read state, as Read state, unread and receipts explains. In a channel whose read events are off it shows only the sending status, and it shows nothing for a failed, error or deleted message.
Each channel and each thread keeps the request of a failed send in memory for five minutes, for up to 100 messages, so a retry sends exactly what failed. After that, or after the app restarts, 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, and the call resolves.
Optimistic updates
The operations change the list before the request goes out, and the server's copy replaces the local one when it answers:
| Operation | Shown at once | When the request fails |
|---|---|---|
MessageOperations.send() and MessageOperations.retry() |
the message, as sending |
the message stays, marked failed, ready to retry |
MessageOperations.update() |
the new text, marked as edited (a message that never reached the server isn't marked) | the edit stays, and the message is marked failed; retrying or editing again sends the edit |
MessageOperations.delete() |
the message as deleted, or removed for a hard delete | the message comes back |
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.
The SDK's reaction actions go through these operations: useMessageOperations() picks the open thread's operations for a message the thread holds, and the channel's otherwise. 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.
Offline
With offline support, every operation on this page that fails because the device is offline is kept as a pending task in the offline database, and sent after the next reconnect, in the order the operations were made. A request the server refuses for good, for example for missing permissions, isn't kept.
- A send is still marked
failed, so the user can retry it, and the pending task sends it after the reconnect. - An edit, a delete or a reaction isn't failed but pending: its change stays in the list until the pending task has been sent.
The operation rejects in both cases, as for any failed request.
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, and hands it the default request, so the handler can still send the usual way:
import { StreamChat } from "stream-chat";
import type { OperationRequestFn } from "stream-chat";
const client = StreamChat.getInstance("YOUR_API_KEY");
const sendMessageRequest: OperationRequestFn<"send"> = (
params,
defaultRequest,
) =>
defaultRequest({
...params,
options: { ...params.options, skip_push: true },
});
client.config.set({ channel: { requestHandlers: { sendMessageRequest } } });updateMessageRequest and deleteMessageRequest change the options of an edit or a delete the same way.
Handlers are registered for the client and apply to every channel, the ones that exist and the ones created later. To treat channels differently, branch inside the handler, for example on params.localMessage.cid. There is 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 | 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 } |
markReadRequest |
Channel.markReadViaReporter() and the SDK's own marking read |
{ channel, options } |
the mark-read response, or null |
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.
markReadRequest has no default request. To mark read the usual way, call Channel.markRead(options) in it. A direct Channel.markRead() call doesn't go through the handler. Marking read is explained on Read state and unread counts.
Wrapping the default request
The default request is the one the operation would make without a handler, and it's the second argument of every message handler. Call it to send the usual way, with the parameters as given or changed. Leave it out to send somewhere else:
import { StreamChat } from "stream-chat";
import type { OperationRequestFn } from "stream-chat";
const client = StreamChat.getInstance("YOUR_API_KEY");
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.
A handler replaces the default request, so a request it sends some other way isn't kept as a pending task while the device is offline.
Threads use their channel's handlers
A thread reply is sent, retried, edited and deleted through the handlers registered under channel. The thread key takes one handler of its own, markReadRequest, which runs when a thread is marked read. It receives { thread, options }, where options already carries the thread's id, and resolves to null when nothing was sent:
import { AppState } from "react-native";
import { StreamChat } from "stream-chat";
import type { CustomThreadMarkReadRequestFn } from "stream-chat";
const client = StreamChat.getInstance("YOUR_API_KEY");
const markReadRequest: CustomThreadMarkReadRequestFn = async ({
thread,
options,
}) => {
// Keep the thread unread while the app is in the background.
if (AppState.currentState !== "active") return null;
return thread.channel.markRead(options);
};
client.config.set({ thread: { requestHandlers: { markReadRequest } } });Pitfalls
- Resolve with the server's message. A send handler that resolves without a
messageleaves the local copy assendinguntil the server'smessage.newevent replaces it. - Options of an edit. An edit sends only
skip_enrich_urlandskip_pushof its options, the onesMessageOperations.update()was called with or the ones anupdateMessageRequesthandler passes to the default request. The other options are dropped. - Raw calls skip all of this.
Channel.sendMessage()and the like neither show the message first nor mark it failed.