Custom Message Menu

The message menu is the contextual menu that appears when you long press a message. It allows you to perform actions on the message like replying, deleting, etc. Also, it allows you to react to the message/view the message reactions.

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

An example of a custom message menu is as follows. We will show a message menu with the reactions picker and the message actions.

Custom Message Menu

For this example, we have used the default components whereever it was possible to keep the guide as 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.

The message actions are received from the props of the MessageActionsList component.

The MessageUserReactionsItem component is used to render the user reactions in the UI and this is imported from the useMessagesContext hook. The message user reactions is fetched using the useFetchReactions hook.

We show the message/attachments which was long pressed in the message menu using the default components for the respective message types.

The types of the message to display can be shown using the messageContentOrder from the useMessagesContext hook.

For the text, we show the message text in the message menu using the MessageTextContainer component.

For the attachments, we show the attachments in the message menu using the Attachment component. For this we need otherAttachments from the useMessageContext hook.

For the quoted reply, we show the quoted message in the message menu using the Reply component. For this we need message.quoted_message. The message can be accessed from the useMessageContext hook.

For the gallery, we show the gallery in the message menu using the Gallery component. For this we need images from the useMessageContext hook.

For the files, we show the files in the message menu using the FileAttachmentGroup component. For this we need files from the useMessageContext hook.

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<string>("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",
  },
});
© Getstream.io, Inc. All Rights Reserved.