Custom message

Every message in a message list is rendered by the MessageUI component. When restyling the default isn't enough, you replace it, and this recipe builds a replacement step by step.

Note:

What you'll build

  • Your own message UI, with the user's messages on the right
  • Bubbles beside the sender's avatar or initials
  • Formatted text with Markdown, links and mentions
  • Time, Edited mark and delivery status under messages
  • Grouped runs of messages from one sender

Preview

The finished custom message UI: bubbles with initials avatars, the time, delivery status and an Edited mark under each group, and a placeholder for Cara's deleted message

Approach: Replace one part. Choosing where to start explains the approaches.

Starts from: the base app. The page shows only what the recipe adds.

The result shows a message but offers no way to act on it. Action bar for a custom message adds actions and a reply count to it, and Reactions for a custom message adds reactions.

Best practices

  • Restyle the default MessageUI before replacing it. A replacement has to handle every kind of message your app shows.
  • Read the message and its handlers with useMessageContext(). A custom MessageUI receives no props.
  • Render the text with MessageText, so Markdown, links and mentions keep working.
  • Test the result with deleted, failed and edited messages, messages with attachments, and messages in a thread.

Message text and avatars

Start with the simplest message UI: the raw text.

src/CustomMessageUi.tsx
import { useMessageContext } from "stream-chat-react";

export const CustomMessageUi = () => {
  const { message } = useMessageContext();

  return <div>{message.text}</div>;
};
Note:

The message UI and every component inside it read MessageContext with useMessageContext(): the message, the handlers for its actions, and the read and delivery state.

Register it on the MessageUI key with WithComponents:

src/WithCustomMessageUi.tsx
import type { PropsWithChildren } from "react";
import { WithComponents } from "stream-chat-react";
import { CustomMessageUi } from "./CustomMessageUi";

// Declared once, so WithComponents gets the same object on every render.
const overrides = { MessageUI: CustomMessageUi };

export const WithCustomMessageUi = ({ children }: PropsWithChildren) => (
  <WithComponents overrides={overrides}>{children}</WithComponents>
);

Wrap the channel in WithCustomMessageUi. Every message list below it, in the channel and in its threads, then renders messages with CustomMessageUi:

src/ChannelWithCustomMessages.tsx
import type { Channel as StreamChannel } from "stream-chat";
import {
  Channel,
  ChannelHeader,
  MessageComposer,
  MessageList,
} from "stream-chat-react";
import { WithCustomMessageUi } from "./WithCustomMessageUi";

export const ChannelWithCustomMessages = ({
  channel,
}: {
  channel: StreamChannel;
}) => (
  <WithCustomMessageUi>
    <Channel channel={channel}>
      <ChannelHeader />
      <MessageList />
      <MessageComposer />
    </Channel>
  </WithCustomMessageUi>
);

Plain text messages

Messages rendered as plain text, one under the other on the left, with the Markdown and the mention shown as typed

All messages now sit on one side, and you can't tell who sent them. isMyMessage() tells the user's own messages apart, and message.user holds the sender. Put the user's messages on the right and everyone else's on the left. The styles go in CustomMessageUi.css, imported from CustomMessageUi.tsx with import "./CustomMessageUi.css";:

src/CustomMessageUi.tsx
import { useMessageContext } from "stream-chat-react";
import "./CustomMessageUi.css";

export const CustomMessageUi = () => {
  const { isMyMessage, message } = useMessageContext();

  const messageUiClassNames = ["cookbook-message-ui"];

  if (isMyMessage()) {
    messageUiClassNames.push("cookbook-message-ui--mine");
  } else {
    messageUiClassNames.push("cookbook-message-ui--other");
  }

  return (
    <div className={messageUiClassNames.join(" ")}>
      <strong className="cookbook-message-ui__name">
        {message.user?.name || message.user?.id}
      </strong>
      <span>{message.text}</span>
    </div>
  );
};

The rules sit in the stream-overrides layer, so they win over the SDK's own rules once the SDK's stylesheet is in an earlier layer, as Theming → CSS layers sets up. They take their colors, spacing and type from the SDK's tokens, so they follow your theme.


With sender names

Messages with the sender's name: Cara, Bob and Dan on the left, Cleo's own message on the right

This already reads better. Next, replace the name with the SDK's Avatar, which shows the user's image, or their initials when they have none, and put the text in a bubble. The bubble takes the colors and borders the SDK's own bubbles use: --str-chat__chat-bg-incoming and --str-chat__chat-border-incoming, and their -outgoing pairs for the user's own messages:

src/CustomMessageUi.tsx
import { Avatar, useMessageContext } from "stream-chat-react";
import "./CustomMessageUi.css";

export const CustomMessageUi = () => {
  const { isMyMessage, message } = useMessageContext();

  const messageUiClassNames = ["cookbook-message-ui"];

  if (isMyMessage()) {
    messageUiClassNames.push("cookbook-message-ui--mine");
  } else {
    messageUiClassNames.push("cookbook-message-ui--other");
  }

  return (
    <div className={messageUiClassNames.join(" ")}>
      <Avatar
        className="cookbook-message-ui__avatar"
        imageUrl={message.user?.image}
        size="md"
        userName={message.user?.name || message.user?.id}
      />
      <span className="cookbook-message-ui__text">{message.text}</span>
    </div>
  );
};

Bubbles and avatars

Messages in bubbles with initials avatars, the user's own message in a light blue bubble on the right

The text is still plain: links, mentions and Markdown show as typed. MessageText renders the message's text through renderText, which turns them into links, mention highlights and formatted text. It reads the message from MessageContext, so it needs no props:

src/CustomMessageUi.tsx
import { Avatar, MessageText, useMessageContext } from "stream-chat-react";
import "./CustomMessageUi.css";

export const CustomMessageUi = () => {
  const { isMyMessage, message } = useMessageContext();

  const messageUiClassNames = ["cookbook-message-ui"];

  if (isMyMessage()) {
    messageUiClassNames.push("cookbook-message-ui--mine");
  } else {
    messageUiClassNames.push("cookbook-message-ui--other");
  }

  return (
    <div className={messageUiClassNames.join(" ")}>
      <Avatar
        className="cookbook-message-ui__avatar"
        imageUrl={message.user?.image}
        size="md"
        userName={message.user?.name || message.user?.id}
      />
      <div className="cookbook-message-ui__text">
        <MessageText />
      </div>
    </div>
  );
};

Formatted text

Messages with formatted text: the mention of Bob is highlighted and "rounded" is bold

Note:

A mention is highlighted but does nothing when clicked. Mentions shows how to handle clicks on it.

Metadata

The messages still lack context. Add the time the message was sent, an "Edited" mark, and, on the user's own messages, whether they were sent, delivered or read.

The server's timestamps are nanosecond numbers, so convert them with convertTimestampToDate before formatting; Data conventions explains why. MessageContext gives the user's own message deliveredTo and readBy, the users the message reached and the users who read it. By default only the latest own message that was delivered or read gets them; returnAllReadData on the list gives them to every own message. The status shows as an icon, so it gets role="img" and an aria-label that screen readers announce instead of the emoji's name. The metadata takes the color the SDK uses for timestamps, --str-chat__chat-text-timestamp. The avatar and a new content column sit side by side, so the metadata lines up under the bubble:

src/CustomMessageUi.tsx
import type { LocalMessage, UserResponse } from "stream-chat";
import { convertTimestampToDate } from "stream-chat";
import {
  Avatar,
  MessageText,
  useChatContext,
  useMessageContext,
  useThreadContext,
} from "stream-chat-react";
import "./CustomMessageUi.css";

const statusIconMap = {
  delivered: { icon: "✅", label: "Delivered" },
  read: { icon: "👁️", label: "Read" },
  sending: { icon: "🛫", label: "Sending" },
  sent: { icon: "✉️", label: "Sent" },
  unknown: { icon: "❓", label: "Status unknown" },
};

type MessageStatusParams = {
  deliveredTo: UserResponse[];
  message: LocalMessage;
  ownUserId: string;
  readBy: UserResponse[];
  isInThread: boolean;
};

const getMessageStatus = ({
  deliveredTo,
  message,
  ownUserId,
  readBy,
  isInThread,
}: MessageStatusParams): keyof typeof statusIconMap => {
  const [firstReader] = readBy;
  const [firstDeliveredUser] = deliveredTo;

  const justReadByMe = readBy.length === 1 && firstReader?.id === ownUserId;
  const read = readBy.length > 0 && !justReadByMe && !isInThread;

  const deliveredOnlyToMe =
    deliveredTo.length === 1 && firstDeliveredUser?.id === ownUserId;
  const delivered =
    deliveredTo.length > 0 && !deliveredOnlyToMe && !read && !isInThread;

  const sent =
    message.status === "received" && !delivered && !read && !isInThread;
  const sending = message.status === "sending";

  if (read) return "read";
  if (delivered) return "delivered";
  if (sent) return "sent";
  if (sending) return "sending";
  return "unknown";
};

const CustomMessageUiMetadata = () => {
  const {
    deliveredTo = [],
    isMyMessage,
    message,
    readBy = [],
  } = useMessageContext();
  const { client } = useChatContext();
  // Read and delivery cursors sit on channel messages, so a thread list's `readBy` / `deliveredTo`
  // say nothing about its replies, and the parent gets none. As in the SDK's `MessageStatus`, a
  // message shown in a thread is never read, delivered or sent here.
  const isInThread = !!useThreadContext();

  const createdAt = convertTimestampToDate(message.created_at);
  const editedAt = convertTimestampToDate(message.message_text_updated_at);
  const status =
    statusIconMap[
      getMessageStatus({
        deliveredTo,
        message,
        ownUserId: client.user?.id ?? "",
        readBy,
        isInThread,
      })
    ];

  return (
    <div className="cookbook-message-ui__metadata">
      <span>
        {createdAt?.toLocaleTimeString([], {
          hour: "numeric",
          minute: "2-digit",
        })}
      </span>
      {isMyMessage() && (
        <span aria-label={status.label} role="img" title={status.label}>
          {status.icon}
        </span>
      )}
      {editedAt && <span title={editedAt.toLocaleString()}>Edited</span>}
    </div>
  );
};

export const CustomMessageUi = () => {
  const { isMyMessage, message } = useMessageContext();

  const messageUiClassNames = ["cookbook-message-ui"];

  if (isMyMessage()) {
    messageUiClassNames.push("cookbook-message-ui--mine");
  } else {
    messageUiClassNames.push("cookbook-message-ui--other");
  }

  return (
    <div className={messageUiClassNames.join(" ")}>
      <Avatar
        className="cookbook-message-ui__avatar"
        imageUrl={message.user?.image}
        size="md"
        userName={message.user?.name || message.user?.id}
      />
      <div className="cookbook-message-ui__content">
        <div className="cookbook-message-ui__text">
          <MessageText />
        </div>
        <CustomMessageUiMetadata />
      </div>
    </div>
  );
};

Time, status and Edited mark

Messages with their time under each bubble, and a delivery status and an Edited mark under the user's own message

The metadata now repeats under every message. MessageList groups consecutive messages from the same sender and puts a class on the list item around each message: str-chat__li--top, --middle, --bottom or --single. Show the metadata only under the last message of a group, and the avatar only next to it:

src/CustomMessageUi.css
@layer stream-overrides {
  .cookbook-message-ui__metadata {
    display: flex;
    display: none;
    align-items: baseline;
    gap: var(--str-chat__spacing-xxs);
    padding-inline: var(--str-chat__spacing-xs);
    color: var(--str-chat__chat-text-timestamp);
    font: var(--str-chat__font-metadata-default);
  }

  .str-chat__li--bottom .cookbook-message-ui__metadata,
  .str-chat__li--single .cookbook-message-ui__metadata {
    display: flex;
  }

  .str-chat__li--top .cookbook-message-ui__avatar,
  .str-chat__li--middle .cookbook-message-ui__avatar {
    visibility: hidden;
  }
}

A grouped run of messages

Cara's two messages grouped: the time shows only under the second one, and the avatar only next to it

Note:

VirtualizedMessageList puts the same classes on its items, so these rules work there too. To make the same decision in code, read groupStyles from MessageContext: it holds the message's place in its group, such as ["bottom"].

The SDK's own MessageStatus and MessageTimestamp handle more cases, such as the list of readers and translated date formats, and need no props. A custom message UI can render them instead of building its own.

Deleted message

A deleted message keeps its place in the list, without its text. isMessageDeleted(message), from stream-chat-react, covers every way a message can be deleted: a soft delete, a message of type deleted, and a message deleted only for the current user. Render a placeholder instead of the content:

src/CustomMessageUi.tsx
export const CustomMessageUi = () => {
  const { isMyMessage, message } = useMessageContext();

  const messageUiClassNames = ["cookbook-message-ui"];
  const isDeleted = isMessageDeleted(message);

  if (isMyMessage()) {
    messageUiClassNames.push("cookbook-message-ui--mine");
  } else {
    messageUiClassNames.push("cookbook-message-ui--other");
  }

  return (
    <div className={messageUiClassNames.join(" ")}>
      <Avatar
        className="cookbook-message-ui__avatar"
        imageUrl={message.user?.image}
        size="md"
        userName={message.user?.name || message.user?.id}
      />
      <div className="cookbook-message-ui__content">
        {isDeleted ? (
          <div className="cookbook-message-ui__deleted">
            This message was deleted.
          </div>
        ) : (
          <>
            <div className="cookbook-message-ui__text">
              <MessageText />
            </div>
            <CustomMessageUiMetadata />
          </>
        )}
      </div>
    </div>
  );
};

The screenshot at the top of this page shows the result: Cara's second message is a placeholder, with no metadata under it.

Complete example

WithCustomMessageUi.tsx is the one from the first step. The whole component and its stylesheet, which Action bar for a custom message starts from:

src/CustomMessageUi.tsx
import type { LocalMessage, UserResponse } from "stream-chat";
import { convertTimestampToDate } from "stream-chat";
import {
  Avatar,
  isMessageDeleted,
  MessageText,
  useChatContext,
  useMessageContext,
  useThreadContext,
} from "stream-chat-react";
import "./CustomMessageUi.css";

const statusIconMap = {
  delivered: { icon: "✅", label: "Delivered" },
  read: { icon: "👁️", label: "Read" },
  sending: { icon: "🛫", label: "Sending" },
  sent: { icon: "✉️", label: "Sent" },
  unknown: { icon: "❓", label: "Status unknown" },
};

type MessageStatusParams = {
  deliveredTo: UserResponse[];
  message: LocalMessage;
  ownUserId: string;
  readBy: UserResponse[];
  isInThread: boolean;
};

const getMessageStatus = ({
  deliveredTo,
  message,
  ownUserId,
  readBy,
  isInThread,
}: MessageStatusParams): keyof typeof statusIconMap => {
  const [firstReader] = readBy;
  const [firstDeliveredUser] = deliveredTo;

  const justReadByMe = readBy.length === 1 && firstReader?.id === ownUserId;
  const read = readBy.length > 0 && !justReadByMe && !isInThread;

  const deliveredOnlyToMe =
    deliveredTo.length === 1 && firstDeliveredUser?.id === ownUserId;
  const delivered =
    deliveredTo.length > 0 && !deliveredOnlyToMe && !read && !isInThread;

  const sent =
    message.status === "received" && !delivered && !read && !isInThread;
  const sending = message.status === "sending";

  if (read) return "read";
  if (delivered) return "delivered";
  if (sent) return "sent";
  if (sending) return "sending";
  return "unknown";
};

const CustomMessageUiMetadata = () => {
  const {
    deliveredTo = [],
    isMyMessage,
    message,
    readBy = [],
  } = useMessageContext();
  const { client } = useChatContext();
  // Read and delivery cursors sit on channel messages, so a thread list's `readBy` / `deliveredTo`
  // say nothing about its replies, and the parent gets none. As in the SDK's `MessageStatus`, a
  // message shown in a thread is never read, delivered or sent here.
  const isInThread = !!useThreadContext();

  const createdAt = convertTimestampToDate(message.created_at);
  const editedAt = convertTimestampToDate(message.message_text_updated_at);
  const status =
    statusIconMap[
      getMessageStatus({
        deliveredTo,
        message,
        ownUserId: client.user?.id ?? "",
        readBy,
        isInThread,
      })
    ];

  return (
    <div className="cookbook-message-ui__metadata">
      <span>
        {createdAt?.toLocaleTimeString([], {
          hour: "numeric",
          minute: "2-digit",
        })}
      </span>
      {isMyMessage() && (
        <span aria-label={status.label} role="img" title={status.label}>
          {status.icon}
        </span>
      )}
      {editedAt && <span title={editedAt.toLocaleString()}>Edited</span>}
    </div>
  );
};

export const CustomMessageUi = () => {
  const { isMyMessage, message } = useMessageContext();

  const messageUiClassNames = ["cookbook-message-ui"];
  const isDeleted = isMessageDeleted(message);

  if (isMyMessage()) {
    messageUiClassNames.push("cookbook-message-ui--mine");
  } else {
    messageUiClassNames.push("cookbook-message-ui--other");
  }

  return (
    <div className={messageUiClassNames.join(" ")}>
      <Avatar
        className="cookbook-message-ui__avatar"
        imageUrl={message.user?.image}
        size="md"
        userName={message.user?.name || message.user?.id}
      />
      <div className="cookbook-message-ui__content">
        {isDeleted ? (
          <div className="cookbook-message-ui__deleted">
            This message was deleted.
          </div>
        ) : (
          <>
            <div className="cookbook-message-ui__text">
              <MessageText />
            </div>
            <CustomMessageUiMetadata />
          </>
        )}
      </div>
    </div>
  );
};

Attachments

This message UI shows no attachments. A custom message UI renders them with the SDK's Attachment, given the message's attachments; Custom attachments › Product card shows how, and how to render an attachment type of your own.

How it works

Info:

Example

The user opens a channel where Cara has sent two messages in a row, and the user has replied:

  1. MessageList renders the MessageUI override, CustomMessageUi, for each message. It reads the message from MessageContext with useMessageContext().
  2. isMyMessage() is false for Cara's messages, so they sit on the left with her avatar. The user's reply sits on the right.
  3. MessageText renders each message's text, with its Markdown and mentions. CustomMessageUiMetadata adds the time, an Edited mark if the text was edited, and on the user's reply the status: sending, sent, delivered or read.
  4. MessageList puts str-chat__li--top on the list item around Cara's first message and str-chat__li--bottom around her second, so the stylesheet shows the metadata and the avatar only with the second.
  5. Cara deletes her second message. isMessageDeleted(message) is now true, so the placeholder renders in place of its text and metadata.

The default MessageUI handles more than this recipe does: