Sending, editing and deleting

What users get

A message appears in the list the moment the user sends it, marked as sending, and settles when the server answers. Users can edit and delete their own messages, and resend one that failed.

  • Sending. The message shows at once with a clock icon, which goes away when the server confirms it. The user's latest message then shows whether it was sent, delivered and read.
  • Failed messages. A message whose request failed stays in the list with an error badge. Its menu offers Resend.
  • Editing. Edit Message loads the message into the composer, with an "Edit Message" preview above the input and a checkmark in place of the send button. The ✕ on the preview cancels the edit and puts back whatever the user was typing before. An edited message shows "Edited", with the time of the edit in a tooltip.
  • Deleting. Delete message asks for confirmation, then shows "Message deleted" in place of the message.
  • Bounced messages. A message that moderation bounced shows in an error style. Clicking it opens a prompt to delete it, edit it, or send it anyway.

How it works across the layers

The work happens in the state layer: the composer builds the message, and the message operations of the channel or thread change the message list and send the request. The components ask for each step and render the result.

Sending

The user types "Hello" in the channel "Design" and presses Enter:

  1. The MessageComposer component calls the state layer's MessageComposer.send() on the composer it drives: channel.messageComposer, or thread.messageComposer inside a thread. See Which composer it drives.
  2. The composer builds the message from its text, attachments, mentions and quoted message, and clears itself before the request goes out. The user can start typing the next message right away.
  3. channel.messageOperations.send() adds "Hello" to the message list with status sending. It sends the request through the sendMessageRequest handler when one is registered, and through the default request otherwise.
  4. When the server answers, its copy replaces the local one with status received. Every list that shows the message updates.
  5. If the request fails, the message stays in the list with status failed, and the composer publishes an error notification.

MessageComposer.send() never throws. It resolves to 'sent', 'nothing-to-send' or 'failed'. Message composer explains each, and Message operations the statuses and the request handlers.

Resending

Resend calls handleRetry from the message context, which calls MessageOperations.retry({ localMessage }) on the channel or the thread. The message goes back to sending.

A message that failed with a 403 error can't be resent. How long a failed request is kept for a retry is on Message statuses.

Editing

Edit Message loads the message into the composer the user types in, the channel's or the thread's, with MessageComposer.initState({ composition: message }). The composer now holds the message as editedMessage. The focus moves to that composer's input, with the caret after the text. The SDK finds the composer from the menu's place in the page, in the nearest thread or channel. If no input shows there, for example while a voice recording runs, the focus stays where it was.

Pressing the checkmark calls MessageComposer.update() instead of MessageComposer.send(). MessageOperations.update() shows the new text at once and marks the message edited. If the request fails, the edit is kept and the message is marked failed. Like MessageComposer.send(), MessageComposer.update() never throws and resolves to the same results.

The ✕ on EditedMessagePreview ends the edit and restores what the composer held before it started. Saving the edit leaves the composer empty. See Editing for the composer's side.

Deleting

Delete message opens a confirmation. Confirming calls handleDelete from the message context, which calls MessageOperations.delete({ localMessage }) on the channel or the thread. The message shows as deleted at once. If the server rejects the request, it comes back.

MessageOperations.delete() can also hard-delete the message or delete it for the current user only: see Operations. What each operation does before and after the server answers is on Optimistic updates.

Bounced messages

When moderation bounces a message, the server returns it with type error and the moderation action bounce. The message list shows it in an error style, and the message becomes clickable.

Clicking it opens MessageBounceModal with MessageBouncePrompt, which offers three choices from useMessageBounceContext():

  • Delete removes the message from the channel's message list, without a request. A bounced reply stays in its thread.
  • Edit Message loads it into the composer, as the Edit action does.
  • Send Anyway resends it with MessageOperations.retry().

What renders each part

Part Component Reads
The input and the send button MessageComposer the channel's, thread's or edit composer
The preview above the input while editing EditedMessagePreview composer.state.editedMessage
Sending, sent, delivered and read icons MessageStatus the message's status and the read state
"Edited" MessageEditedIndicator message.message_text_updated_at
A deleted message MessageDeletedBubble, or MessageDeleted to replace the whole message deleted_at, type: 'deleted' or deleted_for_me
The bounce prompt MessageBouncePrompt useMessageBounceContext()
Edit, Delete and Resend MessageActions, with the default message action set the user's capabilities and the message state

Turning it on or off

Sending, editing and deleting are always available. The user's capabilities in the channel decide who can use them:

Action Capability
Send a message send-message
Edit a message update-own-message or update-any-message
Delete a message delete-own-message or delete-any-message

The message actions follow these capabilities: Edit Message and Delete message appear only when the user may use them. A message that is still sending has no actions, a failed message gets at most Resend, Edit Message and Delete message, and a bounced message has its choices in the bounce prompt instead of a menu. The full rules are on Message actions.

The server checks send-message, but the default composer doesn't hide itself when the user lacks it. To show something else instead, check the capability:

import {
  MessageComposer,
  useChannel,
  useChannelCapabilities,
} from "stream-chat-react";

export const ComposerIfAllowed = () => {
  const channel = useChannel();
  const capabilities = useChannelCapabilities({ cid: channel.cid });

  if (!capabilities.has("send-message")) {
    return (
      <p className="composer-read-only">You can't post in this channel.</p>
    );
  }

  return <MessageComposer />;
};

Customizing

Changing the request

Register a request handler in client.config to change how a message is sent, edited or deleted. A handler receives the default request, so it can wrap it. This one records every sent message:

client.config.set({
  channel: {
    requestHandlers: {
      sendMessageRequest: async (params, defaultRequest) => {
        const response = await defaultRequest(params);
        analytics.track("message_sent", { cid: params.localMessage.cid });
        return response;
      },
    },
  },
});

The handlers for editing and deleting are updateMessageRequest and deleteMessageRequest, and retrySendMessageRequest handles resends. Threads use their channel's handlers. See Customizing requests.

Changing the actions

Edit, Delete and Resend are items of the message action set. To remove, replace or add actions, see Actions and Message actions.

Replacing the bounce prompt

MessageBouncePrompt is an override key. A replacement reads the bounced message and the three handlers from useMessageBounceContext(), and closes the modal with useModalContext():

import type { MessageBouncePromptProps } from "stream-chat-react";
import {
  useMessageBounceContext,
  useModalContext,
  WithComponents,
} from "stream-chat-react";

const BouncePrompt = (_props: MessageBouncePromptProps) => {
  const { handleDelete, handleEdit, message } = useMessageBounceContext();
  const { close } = useModalContext();

  return (
    <div className="bounce-prompt">
      <p>“{message.text}” wasn't sent: it breaks the community guidelines.</p>
      <button
        onClick={(event) => {
          handleEdit(event);
          close();
        }}
        type="button"
      >
        Edit
      </button>
      <button
        onClick={(event) => {
          handleDelete(event);
          close();
        }}
        type="button"
      >
        Delete
      </button>
    </div>
  );
};

export const App = () => (
  <WithComponents overrides={{ MessageBouncePrompt: BouncePrompt }}>
    {/* the rest of the app */}
  </WithComponents>
);

MessageDeletedBubble, MessageDeleted, EditedMessagePreview, MessageEditedIndicator and MessageStatus are override keys too. See Overriding components.

Editing a message in an edit composer

By default, an edit happens in the composer at the bottom of the channel or thread. To edit a message where it sits, give it a composer of its own: an edit composer, whose composition context is the message. Supply it to a MessageComposer with MessageComposerControllerProvider:

import { useEffect, useState } from "react";
import type { LocalMessage, MessageComposerState } from "stream-chat";
import { MessageComposer as MessageComposerController } from "stream-chat";
import {
  MessageComposer,
  MessageComposerControllerProvider,
  useChatContext,
  useStateStore,
} from "stream-chat-react";

const editingSelector = (state: MessageComposerState) => ({
  isEditing: state.editedMessage !== null,
});

export const InlineEditor = ({
  message,
  onDone,
}: {
  message: LocalMessage;
  onDone: () => void;
}) => {
  const { client } = useChatContext();
  const [composer] = useState(
    () =>
      new MessageComposerController({
        client,
        composition: message,
        compositionContext: message,
      }),
  );
  const { isEditing } = useStateStore(composer.state, editingSelector);

  // Saving or cancelling the edit empties the composer, which ends the edit.
  useEffect(() => {
    if (!isEditing) onDone();
  }, [isEditing, onDone]);

  if (!isEditing) return null;

  return (
    <MessageComposerControllerProvider messageComposerController={composer}>
      <MessageComposer />
    </MessageComposerControllerProvider>
  );
};

Render InlineEditor in place of the message text from a custom MessageUI, and open it from a custom Edit action. The composer the user was typing in stays untouched. See Message composer UI for how a supplied composer is picked.

Reference