Info:
This is release candidate documentation for Stream Chat React SDK v15. For the latest stable version, see the latest version (v14).

Custom message actions

Message actions are the buttons beside a message and the items in its menu. This recipe builds one action set from the SDK's default, step by step: it adds an item to the menu, adds a button beside the message, and removes two default actions. How action sets work is explained in Actions, and what each default action does in Message actions.

Best practices

  • Start from defaultMessageActionSet and add, remove or replace items. A list written from scratch loses the built-in actions, and the "more" button with them.
  • Declare the set once, outside any component. The base filter recomputes whenever it gets a new list.
  • Keep the base filter on. It hides the default actions from users who can't take them, and lets your own actions through.
  • Check permissions in your own actions. The filter doesn't know their types, so it always shows them.
  • Close the menu when a menu action is done, with closeMenu() from useContextMenuContext().
  • Register the wrapper on the MessageActions key with WithComponents, so it applies in every message list, in channels and in threads.
  • Check the result in a thread too. The filter removes pin, reply and markUnread from thread replies.

Built-in actions

defaultMessageActionSet holds these items, in this order. The order of the list is the order on screen.

Placement Types
quick-dropdown-toggle none: the "more" button that opens the menu
quick reply, react
dropdown react, reply, quote, download, pin, copyMessageText, resendMessage, edit, markUnread, remindMe, saveForLater, flag, mute, delete, blockUser

quick items render beside the message, and dropdown items in the menu. Before rendering, the base filter drops what doesn't apply to the message and the user. For example, markUnread shows only on other users' messages, and only with the read-events capability. Message actions lists when each one shows.

The filter doesn't check the pin-message capability: Pin shows to every user on channel messages. Pinned messages shows how to hide it from users who can't pin.

Add a custom dropdown action

The menu gets a "Yell" item that shows the message's text in capitals. A dropdown item's component renders inside the menu with no props, and reads what it needs from context:

src/CustomMessageActions.tsx
import type { PropsWithChildren } from "react";
import type { MessageActionSetItem } from "stream-chat-react";
import {
  ContextMenuButton,
  defaultMessageActionSet,
  MessageActions,
  useComponentContextIcons,
  useContextMenuContext,
  useMessageContext,
  WithComponents,
} from "stream-chat-react";

const YellAction = () => {
  const { IconMegaphone } = useComponentContextIcons();
  const { closeMenu } = useContextMenuContext();
  const { message } = useMessageContext();

  if (!message.text) return null;

  return (
    <ContextMenuButton
      Icon={IconMegaphone}
      onClick={() => {
        window.alert(message.text?.toUpperCase());
        closeMenu();
      }}
    >
      Yell
    </ContextMenuButton>
  );
};

// Declared once: the base filter recomputes whenever it gets a new list.
const messageActionSet: MessageActionSetItem[] = [
  ...defaultMessageActionSet,
  { Component: YellAction, placement: "dropdown", type: "yell" },
];

const CustomMessageActions = () => (
  <MessageActions messageActionSet={messageActionSet} />
);

const overrides = { MessageActions: CustomMessageActions };

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

Wrap the channel, or the whole app, in WithCustomMessageActions.

  1. The default MessageUI renders the MessageActions key on every message that isn't deleted. CustomMessageActions renders the SDK's MessageActions with the new list.
  2. The base filter runs on the list. yell is a type it doesn't know, so the filter lets the item through, and YellAction itself returns null for a message without text.
  3. The item goes last in the menu, because it's last in the list. Put it elsewhere in the list to move it.
  4. ContextMenuButton gives it the look of the other menu items, and useComponentContextIcons() gives it an icon that follows icon overrides.

The "more" button is the quick-dropdown-toggle item of the default set, so spreading the default set keeps it.

Add a quick action

A quick action renders beside the message, next to the reply and reaction buttons. This one copies the message's id. It has no menu to close:

src/CustomMessageActions.tsx
const CopyIdAction = () => {
  const { IconCopy } = useComponentContextIcons();
  const { message } = useMessageContext();

  return (
    <QuickMessageActionsButton
      aria-label="Copy message ID"
      onClick={() => void navigator.clipboard.writeText(message.id)}
    >
      <IconCopy />
    </QuickMessageActionsButton>
  );
};

const messageActionSet: MessageActionSetItem[] = [
  ...defaultMessageActionSet,
  { Component: CopyIdAction, placement: "quick", type: "copyId" },
  { Component: YellAction, placement: "dropdown", type: "yell" },
];

QuickMessageActionsButton is the button the default quick actions use. Give it an aria-label: the icon is its only content.

Filter the defaults

To remove default actions, filter them out by type. This set drops Mark as unread and Block User, and keeps the two actions from above. It's the whole file:

src/CustomMessageActions.tsx
import type { PropsWithChildren } from "react";
import type { MessageActionSetItem } from "stream-chat-react";
import {
  ContextMenuButton,
  defaultMessageActionSet,
  MessageActions,
  QuickMessageActionsButton,
  useComponentContextIcons,
  useContextMenuContext,
  useMessageContext,
  WithComponents,
} from "stream-chat-react";

const YellAction = () => {
  const { IconMegaphone } = useComponentContextIcons();
  const { closeMenu } = useContextMenuContext();
  const { message } = useMessageContext();

  if (!message.text) return null;

  return (
    <ContextMenuButton
      Icon={IconMegaphone}
      onClick={() => {
        window.alert(message.text?.toUpperCase());
        closeMenu();
      }}
    >
      Yell
    </ContextMenuButton>
  );
};

const CopyIdAction = () => {
  const { IconCopy } = useComponentContextIcons();
  const { message } = useMessageContext();

  return (
    <QuickMessageActionsButton
      aria-label="Copy message ID"
      onClick={() => void navigator.clipboard.writeText(message.id)}
    >
      <IconCopy />
    </QuickMessageActionsButton>
  );
};

const hiddenTypes = new Set(["blockUser", "markUnread"]);

// Declared once: the base filter recomputes whenever it gets a new list.
const messageActionSet: MessageActionSetItem[] = [
  // The "more" button has no type, so it's kept explicitly.
  ...defaultMessageActionSet.filter(
    (item) =>
      item.placement === "quick-dropdown-toggle" || !hiddenTypes.has(item.type),
  ),
  { Component: CopyIdAction, placement: "quick", type: "copyId" },
  { Component: YellAction, placement: "dropdown", type: "yell" },
];

const CustomMessageActions = () => (
  <MessageActions messageActionSet={messageActionSet} />
);

const overrides = { MessageActions: CustomMessageActions };

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

The base filter still runs on what's left, so the remaining default actions keep following the user's capabilities. Some things to know when you go further:

  • Replacing an action. Keep its type and swap its Component. The filter still treats it as that action. Actions replaces Pin with a version that asks for confirmation and checks the pin-message capability.
  • Turning the filter off. MessageActions takes disableBaseMessageActionSetFilter, which shows your list as it is. Pass it only when your components check every condition the filter checks.
  • A list from scratch. Include a quick-dropdown-toggle item, or the menu items have no button to open them.