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>
);
};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
messageContentOrderto 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.
Example
Example: a custom message menu with a reactions picker and message actions.

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.
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",
},
});