Reactions for a custom message

This recipe adds reactions to the message UI from Action bar for a custom message.

Note:

What you'll build

  • Thumbs-up and thumbs-down buttons in the action bar
  • A click that adds a reaction, and another that removes it
  • Reactions with their counts under each message

Preview

Thumbs-up reactions under two messages, and the action bar over Cara's hovered message with thumbs-up and thumbs-down buttons next to the other actions, thumbs-up marked as the user's own

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

Starts from: the base app, plus Action bar for a custom message. The page shows only what the recipe adds.

Best practices

  • Register the reactions once, as reactionOptions with WithComponents. MessageReactions, the reactions dialog and your own components read them from there.
  • Keep the reaction types stable. Each key is the type stored on the server, so renaming one hides the reactions already given.
  • React through handleReaction(type, event). It adds or removes the reaction, and the message shows the change before the server answers.
  • Show the reaction buttons only to users with the send-reaction capability, which useMessagePermissions(message) reads as canReact.
  • Give each reaction button an aria-label with the reaction's name, and hide the emoji from screen readers.

Register the reactions

The whole files are in the complete example at the end.

Define the reactions and register them as reactionOptions in WithCustomMessageUi, next to the message UI. Each key is the reaction type stored on the server; Reactions explains the format:

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

const reactionOptions: ReactionOptions = {
  quick: {
    "+1": {
      Component: () => <>👍</>,
      name: "Thumbs up",
      unicode: emojiToUnicode("👍"),
    },
    "-1": {
      Component: () => <>👎</>,
      name: "Thumbs down",
      unicode: emojiToUnicode("👎"),
    },
  },
};

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

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

Quick reactions

Give CustomMessageUiActions a button for each quick reaction, in a group of its own. It reads the registered options with useComponentContext(). handleReaction(type, event) adds the reaction, or removes it if the user already reacted with it, and the message shows the change before the server answers. canReact is the send-reaction capability. Import useComponentContext from stream-chat-react:

src/CustomMessageUi.tsx
const CustomMessageUiActions = () => {
  const {
    handleDelete,
    handleFlag,
    handleOpenThread,
    handlePin,
    handleReaction,
    message,
  } = useMessageContext();
  // Pinning and replying work on the channel list, so the thread's messages, the parent
  // included, offer neither, as in the SDK's default message actions.
  const isInThread = !!useThreadContext();
  const { canDelete, canFlag, canPin, canReact, canReply } =
    useMessagePermissions(message);
  const { reactionOptions } = useComponentContext();
  const quickReactions =
    reactionOptions && "quick" in reactionOptions ? reactionOptions.quick : {};
  // The reactions the user has given show as pressed; pressing one again removes it.
  const ownReactionTypes = new Set(
    message.own_reactions?.map(({ type }) => type),
  );

  return (
    <div className="cookbook-message-ui__actions">
      <div className="cookbook-message-ui__actions-group">
        {canPin && !isInThread && (
          <button
            aria-label={message.pinned ? "Unpin" : "Pin"}
            onClick={handlePin}
            title={message.pinned ? "Unpin" : "Pin"}
            type="button"
          >
            <span aria-hidden="true">{message.pinned ? "📍" : "📌"}</span>
          </button>
        )}
        {canDelete && (
          <button
            aria-label="Delete"
            onClick={() => void handleDelete()}
            title="Delete"
            type="button"
          >
            <span aria-hidden="true">🗑️</span>
          </button>
        )}
        {canReply && !isInThread && (
          <button
            aria-label="Open thread"
            onClick={handleOpenThread}
            title="Open thread"
            type="button"
          >
            <span aria-hidden="true">↩️</span>
          </button>
        )}
        {canFlag && (
          <button
            aria-label="Flag message"
            onClick={handleFlag}
            title="Flag message"
            type="button"
          >
            <span aria-hidden="true">🚩</span>
          </button>
        )}
      </div>
      {canReact && (
        <div className="cookbook-message-ui__actions-group">
          {Object.entries(quickReactions).map(([type, { Component, name }]) => (
            <button
              aria-label={`React with ${name}`}
              aria-pressed={ownReactionTypes.has(type)}
              key={type}
              onClick={(event) => void handleReaction(type, event)}
              title={`React with ${name}`}
              type="button"
            >
              <span aria-hidden="true">
                <Component />
              </span>
            </button>
          ))}
        </div>
      )}
    </div>
  );
};

Each quick reaction button has aria-pressed, true when the user has already given that reaction, so a screen reader announces it as pressed. message.own_reactions holds the user's own reactions. The rule added to CustomMessageUi.css marks those buttons as selected.

Reactions on a message

Render MessageReactions, imported from stream-chat-react, under the metadata. It reads the message from MessageContext and the reactions on offer from reactionOptions, shows each reaction with its count, and opens the reactions dialog when clicked:

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 />
            <MessageReactions />
          </>
        )}
      </div>
      {!isDeleted && <CustomMessageUiActions />}
    </div>
  );
};

Complete example

WithCustomMessageUi.tsx is the one from Register the reactions. The whole component and its stylesheet:

src/CustomMessageUi.tsx
import type { LocalMessage, UserResponse } from "stream-chat";
import { convertTimestampToDate } from "stream-chat";
import {
  Avatar,
  isMessageDeleted,
  MessageReactions,
  MessageText,
  useChatContext,
  useComponentContext,
  useMessageContext,
  useMessagePermissions,
  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 = [],
    handleOpenThread,
    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>}
      {!isInThread && message.reply_count > 0 && (
        <span>
          <button
            className="cookbook-message-ui__reply-count"
            onClick={handleOpenThread}
            type="button"
          >
            {message.reply_count}{" "}
            {message.reply_count > 1 ? "replies" : "reply"}
          </button>
        </span>
      )}
    </div>
  );
};

const CustomMessageUiActions = () => {
  const {
    handleDelete,
    handleFlag,
    handleOpenThread,
    handlePin,
    handleReaction,
    message,
  } = useMessageContext();
  // Pinning and replying work on the channel list, so the thread's messages, the parent
  // included, offer neither, as in the SDK's default message actions.
  const isInThread = !!useThreadContext();
  const { canDelete, canFlag, canPin, canReact, canReply } =
    useMessagePermissions(message);
  const { reactionOptions } = useComponentContext();
  const quickReactions =
    reactionOptions && "quick" in reactionOptions ? reactionOptions.quick : {};
  // The reactions the user has given show as pressed; pressing one again removes it.
  const ownReactionTypes = new Set(
    message.own_reactions?.map(({ type }) => type),
  );

  return (
    <div className="cookbook-message-ui__actions">
      <div className="cookbook-message-ui__actions-group">
        {canPin && !isInThread && (
          <button
            aria-label={message.pinned ? "Unpin" : "Pin"}
            onClick={handlePin}
            title={message.pinned ? "Unpin" : "Pin"}
            type="button"
          >
            <span aria-hidden="true">{message.pinned ? "📍" : "📌"}</span>
          </button>
        )}
        {canDelete && (
          <button
            aria-label="Delete"
            onClick={() => void handleDelete()}
            title="Delete"
            type="button"
          >
            <span aria-hidden="true">🗑️</span>
          </button>
        )}
        {canReply && !isInThread && (
          <button
            aria-label="Open thread"
            onClick={handleOpenThread}
            title="Open thread"
            type="button"
          >
            <span aria-hidden="true">↩️</span>
          </button>
        )}
        {canFlag && (
          <button
            aria-label="Flag message"
            onClick={handleFlag}
            title="Flag message"
            type="button"
          >
            <span aria-hidden="true">🚩</span>
          </button>
        )}
      </div>
      {canReact && (
        <div className="cookbook-message-ui__actions-group">
          {Object.entries(quickReactions).map(([type, { Component, name }]) => (
            <button
              aria-label={`React with ${name}`}
              aria-pressed={ownReactionTypes.has(type)}
              key={type}
              onClick={(event) => void handleReaction(type, event)}
              title={`React with ${name}`}
              type="button"
            >
              <span aria-hidden="true">
                <Component />
              </span>
            </button>
          ))}
        </div>
      )}
    </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 />
            <MessageReactions />
          </>
        )}
      </div>
      {!isDeleted && <CustomMessageUiActions />}
    </div>
  );
};

The screenshot at the top of this page shows the result: the user's message and Cara's "Review is on Thursday…" have thumbs-up reactions, and the action bar over Cara's hovered message has the two quick reactions.

How it works

Info:

Example

The user moves the pointer over Cara's message and reacts with thumbs up:

  1. WithCustomMessageUi registers reactionOptions with two quick reactions, +1 and -1, next to the MessageUI override.
  2. The action bar shows. The user has the send-reaction capability, so canReact is true, and CustomMessageUiActions renders a button for each quick reaction it reads with useComponentContext().
  3. The user clicks thumbs up. handleReaction("+1", event) adds the reaction, and the message shows it before the server answers. message.own_reactions now holds +1, so the button's aria-pressed is true and the stylesheet marks it as selected.
  4. MessageReactions under the metadata shows 👍 with its count. Clicking it opens the reactions dialog.
  5. The user clicks thumbs up in the action bar again, and handleReaction removes the reaction.