Custom Message Menu

The message menu appears on long-press. It lets users perform actions (reply, delete, etc.) and add/view reactions.

Best Practices

  • Reuse default menu subcomponents to keep behavior consistent and reduce maintenance.
  • Keep the menu accessible: large tap targets and clear labels for destructive actions.
  • Use messageContentOrder to mirror the main message layout inside the menu.
  • Load reactions lazily to avoid long menu open times on large threads.
  • Dismiss the overlay after an action to avoid stale state.

How to build a custom message menu

To build a custom message menu, you need to override the MessageMenu component in the Channel component.

import { Text, View } from "react-native";
import { MessageMenu } from "stream-chat-react-native";

const CustomMessageMenu = () => {
  return (
    <View>
      <Text>Custom Message Menu</Text>
    </View>
  );
};

const App = () => {
  return (
    <Channel MessageMenu={CustomMessageMenu}>
      {/** MessageList and MessageInput component here */}
    </Channel>
  );
};

Example

Example: a custom message menu with a reactions picker and message actions.

Custom Message Menu

For this example, we use default components wherever possible to keep the guide simple. We used the MessageActionListItem, MessageUserReactionsAvatar and MessageReactionPicker components with the default styles and wrapped them in a View component with the styles for the actions container and the reactions container.

Message actions are provided by the MessageActionsList props.

MessageUserReactionsItem comes from useMessagesContext. Reactions are fetched via useFetchReactions.

We render the long-pressed message/attachments using default components.

Use messageContentOrder from useMessagesContext to determine which message parts to render.

For text, we use MessageTextContainer.

For attachments, use Attachment and otherAttachments from useMessageContext.

For quoted replies, use Reply and message.quoted_message from useMessageContext.

For galleries, use Gallery and images from useMessageContext.

For files, use FileAttachmentGroup and files from useMessageContext.

The styles are added to have the message menu in the center of the screen and have the message bubble style.
import {
  FlatList,
  Modal,
  StyleSheet,
  Text,
  TouchableWithoutFeedback,
  View,
} from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import {
  Attachment,
  FileAttachmentGroup,
  Gallery,
  MessageActionListItem,
  MessageActionListProps,
  MessageAvatar,
  MessageMenuProps,
  MessageReactionPicker,
  MessageTextContainer,
  MessageUserReactionsAvatar,
  MessageUserReactionsProps,
  Poll,
  Reaction,
  Reply,
  ReplyProps,
  StreamingMessageView,
  useChatContext,
  useFetchReactions,
  useMessageContext,
  useMessagesContext,
  useTheme,
  useTranslationContext,
} from "stream-chat-react-native";
import Animated, {
  useSharedValue,
  useAnimatedStyle,
} from "react-native-reanimated";
import { useMemo } from "react";

const CustomMessageActionsList = (props: MessageActionListProps) => {
  const { messageActions } = props;
  return (
    <View style={styles.actionsContainer}>
      {messageActions?.map((action) => (
        <MessageActionListItem key={action.title} {...action} />
      ))}
    </View>
  );
};

const CustomMessageReactions = (props: MessageUserReactionsProps) => {
  const { reactions: propReactions, selectedReaction } = props;
  const { supportedReactions, MessageUserReactionsItem } = useMessagesContext();
  const { message } = useMessageContext();
  const { loadNextPage, reactions: fetchedReactions } = useFetchReactions({
    message,
    reactionType: selectedReaction,
    sort: {
      created_at: -1,
    },
  });
  const { t } = useTranslationContext();

  const reactions = useMemo(
    () =>
      propReactions ||
      (fetchedReactions.map((reaction) => ({
        id: reaction.user?.id,
        image: reaction.user?.image,
        name: reaction.user?.name,
        type: reaction.type,
      })) as Reaction[]),
    [propReactions, fetchedReactions],
  );

  const renderItem = ({ item }: { item: Reaction }) => (
    <MessageUserReactionsItem
      MessageUserReactionsAvatar={MessageUserReactionsAvatar}
      reaction={item}
      supportedReactions={supportedReactions ?? []}
    />
  );

  const renderHeader = () => (
    <Text style={[styles.reactionsText]}>{t("Message Reactions")}</Text>
  );

  return (
    <View style={styles.reactionsContainer}>
      <FlatList
        accessibilityLabel="reaction-flat-list"
        columnWrapperStyle={[styles.flatListColumnContainer]}
        contentContainerStyle={[styles.flatListContainer]}
        data={reactions}
        keyExtractor={(item) => item.id}
        ListHeaderComponent={renderHeader}
        numColumns={4}
        onEndReached={loadNextPage}
        renderItem={renderItem}
      />
    </View>
  );
};

export const MessageMenu = (props: MessageMenuProps) => {
  const { client } = useChatContext();
  const {
    alignment,
    files,
    groupStyles,
    images,
    message,
    dismissOverlay,
    handleReaction,
    onlyEmojis,
    otherAttachments,
    threadList,
    videos,
  } = useMessageContext();
  const {
    messageContentOrder,
    messageTextNumberOfLines,
    isMessageAIGenerated,
  } = useMessagesContext();
  const own_reactions =
    message?.own_reactions?.map((reaction) => reaction.type) || [];
  const {
    messageActions,
    showMessageReactions,
    selectedReaction,
    MessageUserReactionsItem,
  } = props;
  const {
    theme: {
      colors: {
        blue_alice,
        grey_gainsboro,
        grey_whisper,
        light_gray,
        transparent,
        white_snow,
      },
      messageSimple: {
        content: {
          container: { borderRadiusL, borderRadiusS },
          receiverMessageBackgroundColor,
          senderMessageBackgroundColor,
        },
      },
    },
  } = useTheme();
  const groupStyle = `${alignment}_${(groupStyles?.[0] || "bottom").toLowerCase()}`;
  const hasThreadReplies = !!message?.reply_count;
  const messageHeight = useSharedValue(0);
  const messageLayout = useSharedValue({ x: 0, y: 0 });
  const messageWidth = useSharedValue(0);

  return (
    <View style={styles.wrapper}>
      <Modal onRequestClose={dismissOverlay} transparent visible={true}>
        <GestureHandlerRootView style={{ flex: 1 }}>
          <TouchableWithoutFeedback
            onPress={dismissOverlay}
            style={{ flex: 1 }}
          >
            <View style={styles.overlay}>
              <MessageReactionPicker
                dismissOverlay={dismissOverlay}
                handleReaction={handleReaction}
                ownReactionTypes={own_reactions}
              />
              <Animated.View
                onLayout={({
                  nativeEvent: {
                    layout: { height: layoutHeight, width: layoutWidth, x, y },
                  },
                }) => {
                  messageLayout.value = {
                    x: alignment === "left" ? x + layoutWidth : x,
                    y,
                  };
                  messageWidth.value = layoutWidth;
                  messageHeight.value = layoutHeight;
                }}
                style={[styles.alignEnd, styles.row]}
              >
                {alignment === "left" && MessageAvatar && (
                  <MessageAvatar
                    {...{ alignment, message, showAvatar: true }}
                  />
                )}
                <View
                  style={[
                    styles.contentContainer,
                    {
                      backgroundColor:
                        onlyEmojis && !message.quoted_message
                          ? transparent
                          : otherAttachments?.length
                            ? otherAttachments[0].type === "giphy"
                              ? !message.quoted_message
                                ? transparent
                                : grey_gainsboro
                              : blue_alice
                            : alignment === "left"
                              ? (receiverMessageBackgroundColor ?? white_snow)
                              : (senderMessageBackgroundColor ?? light_gray),
                      borderBottomLeftRadius:
                        (groupStyle === "left_bottom" ||
                          groupStyle === "left_single") &&
                        (!hasThreadReplies || threadList)
                          ? borderRadiusS
                          : borderRadiusL,
                      borderBottomRightRadius:
                        (groupStyle === "right_bottom" ||
                          groupStyle === "right_single") &&
                        (!hasThreadReplies || threadList)
                          ? borderRadiusS
                          : borderRadiusL,
                      borderColor: grey_whisper,
                    },
                    (onlyEmojis && !message.quoted_message) ||
                    otherAttachments?.length
                      ? { borderWidth: 0 }
                      : {},
                  ]}
                >
                  {messageContentOrder.map(
                    (messageContentType, messageContentOrderIndex) => {
                      switch (messageContentType) {
                        case "quoted_reply":
                          return (
                            message.quoted_message && (
                              <View
                                key={`quoted_reply_${messageContentOrderIndex}`}
                                style={[styles.replyContainer]}
                              >
                                <Reply
                                  quotedMessage={
                                    message.quoted_message as ReplyProps["quotedMessage"]
                                  }
                                />
                              </View>
                            )
                          );
                        case "gallery":
                          return (
                            <Gallery
                              alignment={alignment}
                              groupStyles={groupStyles}
                              hasThreadReplies={!!message?.reply_count}
                              images={images}
                              key={`gallery_${messageContentOrderIndex}`}
                              message={message}
                              threadList={threadList}
                              videos={videos}
                            />
                          );
                        case "files":
                          return (
                            <FileAttachmentGroup
                              files={files}
                              key={`file_attachment_group_${messageContentOrderIndex}`}
                              messageId={message.id}
                            />
                          );
                        case "poll":
                          const pollId = message.poll_id;
                          const poll = pollId && client.polls.fromState(pollId);
                          return poll ? (
                            <Poll message={message} poll={poll} />
                          ) : null;
                        case "ai_text": {
                          return isMessageAIGenerated?.(message) ? (
                            <StreamingMessageView
                              key={`ai_message_text_container_${messageContentOrderIndex}`}
                              message={message}
                            />
                          ) : null;
                        }
                        case "attachments":
                          return otherAttachments?.map(
                            (attachment, attachmentIndex) => (
                              <Attachment
                                attachment={attachment}
                                key={`${message.id}-${attachmentIndex}`}
                              />
                            ),
                          );
                        case "text":
                        default:
                          return (otherAttachments?.length &&
                            otherAttachments[0].actions) ||
                            isMessageAIGenerated?.(message) ? null : (
                            <MessageTextContainer
                              key={`message_text_container_${messageContentOrderIndex}`}
                              messageOverlay
                              messageTextNumberOfLines={
                                messageTextNumberOfLines
                              }
                              onlyEmojis={onlyEmojis}
                            />
                          );
                      }
                    },
                  )}
                </View>
              </Animated.View>
              {showMessageReactions ? (
                <CustomMessageReactions
                  message={message}
                  MessageUserReactionsAvatar={MessageUserReactionsAvatar}
                  MessageUserReactionsItem={MessageUserReactionsItem}
                  selectedReaction={selectedReaction}
                />
              ) : (
                <CustomMessageActionsList
                  dismissOverlay={dismissOverlay}
                  MessageActionListItem={MessageActionListItem}
                  messageActions={messageActions}
                />
              )}
            </View>
          </TouchableWithoutFeedback>
        </GestureHandlerRootView>
      </Modal>
    </View>
  );
};

const styles = StyleSheet.create({
  alignEnd: { alignItems: "flex-end" },
  wrapper: {
    flex: 1,
  },
  overlay: {
    flex: 1,
    backgroundColor: "#000000CC",
    justifyContent: "center",
    alignItems: "center",
  },
  actionsContainer: {
    backgroundColor: "white",
    borderRadius: 8,
    padding: 16,
  },
  contentContainer: {
    borderTopLeftRadius: 16,
    borderTopRightRadius: 16,
    borderWidth: 1,
    overflow: "hidden",
    marginVertical: 8,
  },
  replyContainer: {
    flexDirection: "row",
    paddingHorizontal: 8,
    paddingTop: 8,
  },
  row: { flexDirection: "row" },
  reactionsContainer: {
    maxHeight: 200,
    backgroundColor: "white",
    borderRadius: 8,
    padding: 16,
    maxWidth: 500,
  },
  flatListColumnContainer: {
    justifyContent: "space-evenly",
  },
  flatListContainer: {
    justifyContent: "center",
  },
  reactionsText: {
    fontSize: 16,
    fontWeight: "bold",
    marginVertical: 16,
    textAlign: "center",
  },
});