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
defaultMessageActionSetand 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()fromuseContextMenuContext(). - Register the wrapper on the
MessageActionskey withWithComponents, so it applies in every message list, in channels and in threads. - Check the result in a thread too. The filter removes
pin,replyandmarkUnreadfrom 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:
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.
- The default
MessageUIrenders theMessageActionskey on every message that isn't deleted.CustomMessageActionsrenders the SDK'sMessageActionswith the new list. - The base filter runs on the list.
yellis a type it doesn't know, so the filter lets the item through, andYellActionitself returnsnullfor a message without text. - The item goes last in the menu, because it's last in the list. Put it elsewhere in the list to move it.
ContextMenuButtongives it the look of the other menu items, anduseComponentContextIcons()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:
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:
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
typeand swap itsComponent. The filter still treats it as that action. Actions replaces Pin with a version that asks for confirmation and checks thepin-messagecapability. - Turning the filter off.
MessageActionstakesdisableBaseMessageActionSetFilter, 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-toggleitem, or the menu items have no button to open them.