# Custom message actions

> **Info:** This is release candidate documentation for **Stream Chat React Native SDK v10**. For the latest stable version, see the [latest version (v9)](https://getstream.io/chat/docs/sdk/react-native/).

Message actions are the items below a message when its menu is open. This UI cookbook builds one set of actions from the SDK's defaults, step by step: it shares one function between channels and threads, adds an action of its own, removes and reorders default actions, and changes how each item renders. How the actions are built is explained in [Actions](https://getstream.io/chat/docs/sdk/react-native/v10/ui/customizing/actions/#message-actions), and what each default action does in [Message actions](https://getstream.io/chat/docs/sdk/react-native/v10/features/messaging/message-actions/).

## Best practices

- Start from the SDK's `messageActions` function and add, remove or replace items. It applies the default rules: the user's capabilities, own messages, threads and failed messages.
- Check the conditions of your own actions yourself. Nothing filters them.
- Pass the same function to `Channel` and `Thread`. A `Thread` doesn't inherit props from `Channel`, so thread messages get the defaults otherwise.
- Keep the function stable by declaring it at module scope, or building it with `useCallback`.
- Don't close the menu in an action. The menu item closes it, and runs the action once it has closed.
- Give every item a distinct `title`. The default `MessageActionList` uses the titles as keys.

## One set for channels and threads

The function gets everything it needs from its parameter, but your own actions need the icons, the theme and the translation function. A hook builds the function once, and both screens use it:

```tsx title="src/useSupportMessageActions.tsx"
import { useCallback } from "react";
import type {
  Channel as StreamChannel,
  Thread as StreamThread,
} from "stream-chat";
import {
  Channel,
  MessageComposer,
  MessageList,
  messageActions as defaultMessageActions,
  Thread,
} from "stream-chat-react-native";
import type { MessageActionsProp } from "stream-chat-react-native";

export const useSupportMessageActions = () =>
  useCallback<MessageActionsProp>(
    (params) => defaultMessageActions(params),
    [],
  );

export const ChannelScreen = ({ channel }: { channel: StreamChannel }) => {
  const messageActions = useSupportMessageActions();

  return (
    <Channel channel={channel} messageActions={messageActions}>
      <MessageList />
      <MessageComposer />
    </Channel>
  );
};

export const ThreadScreen = ({ thread }: { thread: StreamThread }) => {
  const messageActions = useSupportMessageActions();

  return (
    <Thread messageActions={messageActions} thread={thread}>
      <MessageList />
      <MessageComposer />
    </Thread>
  );
};
```

The menu shows exactly what it showed before. When a menu opens, the SDK builds every default action for the message and calls the function with them, with the message, `isMyMessage`, `isThreadMessage`, `error` and the user's `ownCapabilities`. `defaultMessageActions(params)` returns the list the SDK would show, in its order. The [table on Actions](https://getstream.io/chat/docs/sdk/react-native/v10/ui/customizing/actions/#what-the-default-list-holds) says when each default action is in it.

## Add an action

<table>
  <tr>
  <td align='center' width='50%'>

![The default message menu before the change, with Thread Reply, Reply, Pin, Edit, Copy and Delete on iOS](https://getstream.io/docs-assets/images/f145b10e2276.png)

  </td>
  <td align='center' width='50%'>

![The default message menu before the change, with Thread Reply, Reply, Pin, Edit, Copy and Delete on Android](https://getstream.io/docs-assets/images/04aa460b9b67.png)

  </td>
  </tr>
</table>

Support agents answer some questions in private. A "Reply privately" item on other users' messages opens a direct conversation with the author. Opening it is your app's navigation, so the hook takes it as a callback:

```tsx title="src/useSupportMessageActions.tsx"
import { useCallback } from "react";
import {
  asDynamicKey,
  messageActions as defaultMessageActions,
  useComponentsContext,
  useTheme,
  useTranslationContext,
} from "stream-chat-react-native";
import type {
  MessageActionsProp,
  MessageActionType,
} from "stream-chat-react-native";

export const useSupportMessageActions = (
  onReplyPrivately: (userId: string) => void,
) => {
  const { icons } = useComponentsContext();
  const { t } = useTranslationContext();
  const {
    theme: { semantics },
  } = useTheme();

  return useCallback<MessageActionsProp>(
    (params) => {
      const actions = defaultMessageActions(params);

      const authorId = params.message.user?.id;
      if (params.isMyMessage || params.error || !authorId) return actions;

      const replyPrivately: MessageActionType = {
        action: () => onReplyPrivately(authorId),
        actionType: "replyPrivately",
        icon: (
          <icons.MessageBubbleEmpty
            height={20}
            stroke={semantics.textSecondary}
            width={20}
          />
        ),
        title: t(
          asDynamicKey("support.message.replyPrivately.label"),
          "Reply Privately",
        ),
        type: "standard",
      };

      return [...actions, replyPrivately];
    },
    [icons, onReplyPrivately, semantics.textSecondary, t],
  );
};
```

<table>
  <tr>
  <td align='center' width='50%'>

![The message menu with Copy Message first and a Reply Privately action added at the end on iOS](https://getstream.io/docs-assets/images/d050b0bd5073.png)

  </td>
  <td align='center' width='50%'>

![The message menu with Copy Message first and a Reply Privately action added at the end on Android](https://getstream.io/docs-assets/images/173112fd4e5d.png)

  </td>
  </tr>
</table>

1. The user long-presses a message from someone else, and the menu opens.
2. The function adds `replyPrivately` after the default actions. `type: "standard"` puts it with the other standard actions, above the separator; `"destructive"` would put it after the separator, with Delete Message and Block User.
3. The user taps it. The item closes the menu, and calls `action` once the menu has closed, so your navigation doesn't run under the closing animation.

The icon is a 20 by 20 icon from the `icons` key, like the default actions use, so it follows your [icon overrides](https://getstream.io/chat/docs/sdk/react-native/v10/ui/customizing/overriding-components/#icons). `asDynamicKey` lets `t` take a key of your own, and the second argument is the English copy until you register a translation. [Localization](https://getstream.io/chat/docs/sdk/react-native/v10/ui/customizing/localization/#your-own-keys-and-loosetranslationdictionary) explains both.

Pass `onReplyPrivately` from each screen. Stable callbacks keep the function stable, for example one built with `useCallback` from your navigation.

## Remove and reorder actions

Agents don't block customers, and marking a customer's message unread isn't useful to them. Filter those actions out by `actionType`. Copy Message is the action agents use most, so it moves to the top:

```tsx
const hidden = new Set(["blockUser", "markUnread"]);

// inside the function
const actions = defaultMessageActions(params).filter(
  (action) => !hidden.has(action.actionType),
);

const copyIndex = actions.findIndex(
  (action) => action.actionType === "copyMessage",
);
if (copyIndex > 0) actions.unshift(...actions.splice(copyIndex, 1));
```

The remaining defaults still follow the default rules, because they come from `defaultMessageActions(params)`. Copy Message isn't in the list when the message has no text or no clipboard package is installed, so `findIndex` returns `-1` and the order stays as it is.

To change one default action, spread it and replace a field. `{ ...action, title: "Delete for Everyone" }` keeps the delete's handler and its confirmation dialog.

## Change how items render

The list below the message is the `MessageActionList` key, and each item the `MessageActionListItem` key. An item receives one action as its props. This one renders smaller rows, and closes the menu and runs the action the way the default item does:

```tsx title="src/CompactActionItem.tsx"
import { StyleSheet, Text } from "react-native";
import { Pressable } from "react-native-gesture-handler";
import {
  closeOverlay,
  scheduleActionOnClose,
  useTheme,
} from "stream-chat-react-native";
import type {
  ComponentOverrides,
  MessageActionListItemProps,
} from "stream-chat-react-native";

const CompactActionItem = ({
  action,
  icon,
  title,
  titleStyle,
}: MessageActionListItemProps) => {
  const {
    theme: { semantics },
  } = useTheme();

  return (
    <Pressable
      accessibilityLabel={title}
      accessibilityRole="menuitem"
      onPress={() => {
        closeOverlay();
        scheduleActionOnClose(() => action());
      }}
      style={({ pressed }) => [
        styles.row,
        pressed && { backgroundColor: semantics.backgroundUtilityPressed },
      ]}
    >
      {icon}
      <Text
        style={[styles.title, { color: semantics.textPrimary }, titleStyle]}
      >
        {title}
      </Text>
    </Pressable>
  );
};

export const actionOverrides: ComponentOverrides = {
  MessageActionListItem: CompactActionItem,
};

const styles = StyleSheet.create({
  row: {
    alignItems: "center",
    borderRadius: 8,
    flexDirection: "row",
    gap: 8,
    paddingHorizontal: 8,
    paddingVertical: 6,
  },
  title: { fontSize: 14 },
});
```

Pass `actionOverrides` to a `WithComponents` around `Channel` and `Thread`.

- `closeOverlay()` starts closing the menu, and `scheduleActionOnClose()` runs the action once it has closed. An action that changes the message list, such as a delete or a quote, would otherwise run under the closing animation.
- `titleStyle` comes last, so the red titles of the destructive actions stay red.
- `Pressable` comes from `react-native-gesture-handler`, as in the default item.

`MessageActionList` receives the whole list as `messageActions`. Replace it to change the layout of the menu, for example a grid of icons. The default renders the `standard` actions, a separator, and the `destructive` ones, each with the `MessageActionListItem` key.

## Complete example

```tsx title="src/SupportScreens.tsx"
import { useCallback } from "react";
import { StyleSheet, Text } from "react-native";
import { Pressable } from "react-native-gesture-handler";
import type {
  Channel as StreamChannel,
  Thread as StreamThread,
} from "stream-chat";
import {
  asDynamicKey,
  Channel,
  closeOverlay,
  MessageComposer,
  MessageList,
  messageActions as defaultMessageActions,
  scheduleActionOnClose,
  Thread,
  useComponentsContext,
  useTheme,
  useTranslationContext,
  WithComponents,
} from "stream-chat-react-native";
import type {
  ComponentOverrides,
  MessageActionListItemProps,
  MessageActionsProp,
  MessageActionType,
} from "stream-chat-react-native";

const CompactActionItem = ({
  action,
  icon,
  title,
  titleStyle,
}: MessageActionListItemProps) => {
  const {
    theme: { semantics },
  } = useTheme();

  return (
    <Pressable
      accessibilityLabel={title}
      accessibilityRole="menuitem"
      onPress={() => {
        closeOverlay();
        scheduleActionOnClose(() => action());
      }}
      style={({ pressed }) => [
        styles.row,
        pressed && { backgroundColor: semantics.backgroundUtilityPressed },
      ]}
    >
      {icon}
      <Text
        style={[styles.title, { color: semantics.textPrimary }, titleStyle]}
      >
        {title}
      </Text>
    </Pressable>
  );
};

const actionOverrides: ComponentOverrides = {
  MessageActionListItem: CompactActionItem,
};

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

export const useSupportMessageActions = (
  onReplyPrivately: (userId: string) => void,
) => {
  const { icons } = useComponentsContext();
  const { t } = useTranslationContext();
  const {
    theme: { semantics },
  } = useTheme();

  return useCallback<MessageActionsProp>(
    (params) => {
      const actions = defaultMessageActions(params).filter(
        (action) => !hidden.has(action.actionType),
      );

      const copyIndex = actions.findIndex(
        (action) => action.actionType === "copyMessage",
      );
      if (copyIndex > 0) actions.unshift(...actions.splice(copyIndex, 1));

      const authorId = params.message.user?.id;
      if (params.isMyMessage || params.error || !authorId) return actions;

      const replyPrivately: MessageActionType = {
        action: () => onReplyPrivately(authorId),
        actionType: "replyPrivately",
        icon: (
          <icons.MessageBubbleEmpty
            height={20}
            stroke={semantics.textSecondary}
            width={20}
          />
        ),
        title: t(
          asDynamicKey("support.message.replyPrivately.label"),
          "Reply Privately",
        ),
        type: "standard",
      };

      return [...actions, replyPrivately];
    },
    [icons, onReplyPrivately, semantics.textSecondary, t],
  );
};

type ScreenProps = { onReplyPrivately: (userId: string) => void };

export const ChannelScreen = ({
  channel,
  onReplyPrivately,
}: ScreenProps & { channel: StreamChannel }) => {
  const messageActions = useSupportMessageActions(onReplyPrivately);

  return (
    <WithComponents overrides={actionOverrides}>
      <Channel channel={channel} messageActions={messageActions}>
        <MessageList />
        <MessageComposer />
      </Channel>
    </WithComponents>
  );
};

export const ThreadScreen = ({
  onReplyPrivately,
  thread,
}: ScreenProps & { thread: StreamThread }) => {
  const messageActions = useSupportMessageActions(onReplyPrivately);

  return (
    <WithComponents overrides={actionOverrides}>
      <Thread messageActions={messageActions} thread={thread}>
        <MessageList />
        <MessageComposer />
      </Thread>
    </WithComponents>
  );
};

const styles = StyleSheet.create({
  row: {
    alignItems: "center",
    borderRadius: 8,
    flexDirection: "row",
    gap: 8,
    paddingHorizontal: 8,
    paddingVertical: 6,
  },
  title: { fontSize: 14 },
});
```

## Going further

- **Running code when an action runs.** `handleDelete`, `handleFlag` and the other `handle*` props of `Channel` and `Thread` are called when the user picks a default action, before it runs, which suits analytics. [Actions](https://getstream.io/chat/docs/sdk/react-native/v10/ui/customizing/actions/#running-code-when-an-action-runs) lists them.
- **The same actions outside the menu.** `useMessageActions()` and `useMessageActionHandlers()` run a message's actions from a component inside the message, such as a button in a [custom message](https://getstream.io/chat/docs/sdk/react-native/v10/ui-cookbook/message/custom-message/#swipe-to-delete).
- **A different long press.** `onLongPressMessage` on `Channel` replaces what a long press does, with the default handlers in its payload. [Actions](https://getstream.io/chat/docs/sdk/react-native/v10/ui/customizing/actions/#message-actions-in-your-own-components) shows it.
- **An action that opens a sheet.** The [Status indicators](https://getstream.io/chat/docs/sdk/react-native/v10/ui-cookbook/message/status-indicators/#who-read-a-message) UI cookbook adds a Message Info action that lists who read and who received a message.

---

For the most recent version of this documentation, visit [https://getstream.io/chat/docs/sdk/react-native/v10/ui-cookbook/message/custom-message-actions/](https://getstream.io/chat/docs/sdk/react-native/v10/ui-cookbook/message/custom-message-actions/).