Info:
This is release candidate documentation for Stream Chat React Native SDK v10. For the latest stable version, see the latest version (v9).

Custom message

Every message in a message list is rendered by Message, which creates the message's handlers and renders the MessageItemView key. The default MessageItemView changes a lot through the theme, and each of its parts has its own override key. When that isn't enough, you replace MessageItemView itself. This UI cookbook first decorates the default view, then builds a view of its own step by step, reusing the SDK's parts where they help, and ends with two ways to wrap the default view in a gesture. How overrides apply is explained in Overriding components.

Best practices

  • Restyle the default view, or replace one of its parts, before replacing the whole view. A replacement has to handle every kind of message your app shows.
  • Read the message and its handlers with useMessageContext(). The SDK passes MessageItemView no props.
  • Render the message's content with MessageContent, or its text with MessageTextContainer, so Markdown, links and mentions keep working.
  • Attach contextMenuAnchorRef to the bubble, and make sure a long press reaches onLongPress, or the message menu doesn't open where it should.
  • Declare the override object and the components in it at module scope. A message view renders once per row, so keep it shallow and subscribe it only to what it shows.
  • Test the result with deleted, failed and edited messages, messages with attachments, quoted replies and polls, and messages in a thread.

Decorating the default view

Four keys render around a message's content, inside its bubble: MessageContentTopView, MessageContentBottomView, MessageContentLeadingView and MessageContentTrailingView. A fifth, MessageSpacer, renders last in the message row, beside the content and outside the bubble. They have no default, so the SDK renders nothing for them until you set one. They receive no props and read the message from MessageContext.

This one puts an "Urgent" label above the content of messages that carry a custom priority field:

src/UrgentLabel.tsx
import { StyleSheet, Text } from "react-native";
import { useMessageContext, useTheme } from "stream-chat-react-native";
import type { ComponentOverrides } from "stream-chat-react-native";

declare module "stream-chat" {
  interface CustomMessageData {
    priority?: "high" | "normal";
  }
}

const UrgentLabel = () => {
  const { message } = useMessageContext();
  const {
    theme: { semantics },
  } = useTheme();

  if (message.custom?.priority !== "high") return null;

  return (
    <Text style={[styles.label, { color: semantics.accentError }]}>Urgent</Text>
  );
};

export const urgentOverrides: ComponentOverrides = {
  MessageContentTopView: UrgentLabel,
};

const styles = StyleSheet.create({
  label: {
    fontSize: 12,
    fontWeight: "700",
    paddingHorizontal: 12,
    paddingTop: 8,
  },
});
An outgoing message with a red Urgent label above its text inside the bubble on iOS
An outgoing message with a red Urgent label above its text inside the bubble on Android

Pass urgentOverrides to a WithComponents around your channel screen. The label renders inside the bubble, so it takes the bubble's background and shape. MessageContentLeadingView and MessageContentTrailingView add a row around the content only while one of them is set. Where custom fields come from, and the declare module block that types them, is explained in Data conventions.

The other parts of the default view, such as MessageHeader, MessageFooter, MessageAuthor and MessageStatus, are override keys too. The table on Message list UI lists them.

Building a message view

The rest of this UI cookbook builds ChatMessage, a view with the sender's name above the bubble and the time, the status and the reactions below it. Each step adds one part.

Text and the message menu

Start with the text, and the long press that opens the message menu:

src/ChatMessage.tsx
import { Pressable, StyleSheet, View } from "react-native";
import {
  MessageTextContainer,
  useMessageContext,
} from "stream-chat-react-native";
import type { ComponentOverrides } from "stream-chat-react-native";

const ChatMessage = () => {
  const { alignment, contextMenuAnchorRef, onLongPress } = useMessageContext();

  return (
    <View style={[styles.row, alignment === "right" && styles.rowRight]}>
      <Pressable
        onLongPress={(event) => onLongPress({ emitter: "message", event })}
      >
        <View ref={contextMenuAnchorRef} style={styles.bubble}>
          <MessageTextContainer />
        </View>
      </Pressable>
    </View>
  );
};

export const messageOverrides: ComponentOverrides = {
  MessageItemView: ChatMessage,
};

const styles = StyleSheet.create({
  bubble: { borderRadius: 16, maxWidth: 280, padding: 8 },
  row: { flexDirection: "row", paddingHorizontal: 8, paddingVertical: 2 },
  rowRight: { justifyContent: "flex-end" },
});

Pass messageOverrides to a WithComponents around Channel and Thread, or around the whole app:

  1. Message puts the message, its alignment and its handlers into MessageContext, then renders ChatMessage.
  2. alignment is "right" for the current user's messages and "left" for everyone else's, unless forceAlignMessages on Channel puts them all on one side.
  3. MessageTextContainer renders the text with the SDK's Markdown rules, mentions and links.
  4. A long press calls onLongPress, which opens the message menu: the reactions above the message and the actions below it. The menu lines up with the view in contextMenuAnchorRef.
Note:

Every component inside a message reads MessageContext with useMessageContext(): the message, its alignment and group, its attachments sorted by kind, and the handlers for its actions. Read only what the component shows.

Avatar and name

Messages from the same user, sent one after another, form a group. groupStyles[0] says where a message sits in its group: "top", "middle", "bottom" or "single". The name goes on the first message of a group, and MessageAuthor renders the avatar on the last one, and an empty space of the same width on the others, so the bubbles of a group line up:

const ChatMessage = () => {
  const { alignment, contextMenuAnchorRef, groupStyles, message, onLongPress } =
    useMessageContext();
  const isLeft = alignment === "left";
  const firstInGroup = groupStyles[0] === "top" || groupStyles[0] === "single";

  return (
    <View style={[styles.row, !isLeft && styles.rowRight]}>
      {isLeft ? <MessageAuthor /> : null}
      <View style={[styles.column, !isLeft && styles.columnRight]}>
        {isLeft && firstInGroup ? (
          <Text style={styles.name}>
            {message.user?.name ?? message.user?.id}
          </Text>
        ) : null}
        <Pressable
          onLongPress={(event) => onLongPress({ emitter: "message", event })}
        >
          <View ref={contextMenuAnchorRef} style={styles.bubble}>
            <MessageTextContainer />
          </View>
        </Pressable>
      </View>
    </View>
  );
};

The default view shows the sender's name in the footer of other users' messages, except in channels with exactly two members. Read channel.state through useStateStore if your view needs the member count, with a selector declared at module scope, as Reading state in components explains.

Time, status and edits

The footer shows when the message was sent, whether it was edited, and, on the user's own messages, its status. Server timestamps are nanosecond numbers, and convertTimestampToDate from stream-chat turns one into a Date:

const ChatMessageFooter = () => {
  const { lastGroupMessage, message, showMessageStatus } = useMessageContext();
  const { t } = useTranslationContext();
  const createdAt = useMemo(
    () => convertTimestampToDate(message.created_at),
    [message.created_at],
  );

  // Like the default footer, only the last message of a group shows it, unless it's on its way.
  if (!lastGroupMessage && message.status === "received") return null;

  return (
    <View style={styles.footer}>
      {showMessageStatus ? <MessageStatus /> : null}
      <MessageTimestamp timestamp={createdAt} />
      {isEditedMessage(message) ? (
        <Text style={styles.footerText}>
          {t("message.edited.text", "Edited")}
        </Text>
      ) : null}
    </View>
  );
};

Render <ChatMessageFooter /> under the bubble.

  • MessageTimestamp formats the date with the timestamp.MessageTimestamp translation key, so it follows the user's language and your date formats.
  • showMessageStatus is true on the user's own messages. MessageStatus shows a clock while the message is sending, then one check, two checks once another member received it, and two checks in the accent color once someone read it. The Status indicators UI cookbook changes it.
  • isEditedMessage(message) is true once the text was edited.

Replies and reactions

In a channel, a message that started a thread shows a button with its reply count, which opens the thread. Inside a Thread, useThreadContext().thread is set, and the button doesn't belong there:

const { thread } = useThreadContext();

// under the bubble
<ReactionListBottom />;
{
  !thread && message.reply_count ? <MessageReplies /> : null;
}

ReactionListBottom renders nothing when the message has no reactions. Without a type prop it renders the clustered list, whatever reactionListType on Channel says, and tapping it opens the list of who reacted. MessageReplies calls onOpenThread from the context, which calls the onThreadSelect you passed to the message list with the message's Thread. Both read the message from context, so they need no props.

Attachments, quoted replies and polls

MessageTextContainer renders only the text. Replace it with MessageContent, which renders everything a message can hold, in the order of messageContentOrder on Channel: the quoted message, the gallery, files, a poll, other attachments, a shared location (through the MessageLocation key, which has no default) and the text. It handles taps on each part, and calls onLongPress itself on a long press, so the Pressable can go:

const {
  theme: { semantics },
} = useTheme();

<View ref={contextMenuAnchorRef}>
  <MessageContent
    backgroundColor={
      isMyMessage ? semantics.chatBgOutgoing : semantics.chatBgIncoming
    }
  />
</View>;

MessageContent draws the bubble with the backgroundColor you pass, and leaves it transparent without one. The theme's chatBgOutgoing and chatBgIncoming are the colors the default view uses. The four content slots from Decorating the default view render inside MessageContent, so they keep working.

Deleted and failed messages

A deleted message has type: "deleted" and keeps its place in the list. A message that failed to send has status: "failed", and one the server rejected has type: "error". Tapping one opens the message menu, with retry, edit and delete, and a message that moderation bounced opens a dialog with its options instead. MessageError is the badge the default view puts on its bubble:

if (message.type === "deleted") {
  return (
    <View style={[styles.row, !isLeft && styles.rowRight]}>
      <Text style={styles.deleted}>
        {t("message.deleted.text", "Message deleted")}
      </Text>
    </View>
  );
}

const failed = message.type === "error" || message.status === "failed";

// next to MessageContent
{
  failed ? <MessageError /> : null;
}

System messages, such as "Cleo joined the channel", never reach MessageItemView. The list renders them with MessageSystem. Blocked messages go to MessageBlocked.

Complete example

src/ChatMessage.tsx
import { useMemo } from "react";
import { StyleSheet, Text, View } from "react-native";
import { convertTimestampToDate } from "stream-chat";
import {
  isEditedMessage,
  MessageAuthor,
  MessageContent,
  MessageError,
  MessageReplies,
  MessageStatus,
  MessageTimestamp,
  ReactionListBottom,
  useMessageContext,
  useTheme,
  useThreadContext,
  useTranslationContext,
} from "stream-chat-react-native";
import type { ComponentOverrides } from "stream-chat-react-native";

const ChatMessageFooter = () => {
  const { lastGroupMessage, message, showMessageStatus } = useMessageContext();
  const { t } = useTranslationContext();
  const createdAt = useMemo(
    () => convertTimestampToDate(message.created_at),
    [message.created_at],
  );

  // Like the default footer, only the last message of a group shows it, unless it's on its way.
  if (!lastGroupMessage && message.status === "received") return null;

  return (
    <View style={styles.footer}>
      {showMessageStatus ? <MessageStatus /> : null}
      <MessageTimestamp timestamp={createdAt} />
      {isEditedMessage(message) ? (
        <Text style={styles.footerText}>
          {t("message.edited.text", "Edited")}
        </Text>
      ) : null}
    </View>
  );
};

const ChatMessage = () => {
  const { alignment, contextMenuAnchorRef, groupStyles, isMyMessage, message } =
    useMessageContext();
  const { thread } = useThreadContext();
  const { t } = useTranslationContext();
  const {
    theme: { semantics },
  } = useTheme();

  const isLeft = alignment === "left";
  const firstInGroup = groupStyles[0] === "top" || groupStyles[0] === "single";
  const failed = message.type === "error" || message.status === "failed";

  if (message.type === "deleted") {
    return (
      <View style={[styles.row, !isLeft && styles.rowRight]}>
        <Text style={styles.deleted}>
          {t("message.deleted.text", "Message deleted")}
        </Text>
      </View>
    );
  }

  return (
    <View style={[styles.row, !isLeft && styles.rowRight]}>
      {isLeft ? <MessageAuthor /> : null}
      <View style={[styles.column, !isLeft && styles.columnRight]}>
        {isLeft && firstInGroup ? (
          <Text style={styles.name}>
            {message.user?.name ?? message.user?.id}
          </Text>
        ) : null}
        <View ref={contextMenuAnchorRef} style={styles.bubble}>
          <MessageContent
            backgroundColor={
              isMyMessage ? semantics.chatBgOutgoing : semantics.chatBgIncoming
            }
          />
          {failed ? <MessageError /> : null}
        </View>
        <ReactionListBottom />
        {!thread && message.reply_count ? <MessageReplies /> : null}
        <ChatMessageFooter />
      </View>
    </View>
  );
};

// Declared once, at module scope.
export const messageOverrides: ComponentOverrides = {
  MessageItemView: ChatMessage,
};

const styles = StyleSheet.create({
  bubble: { alignItems: "flex-start", flexDirection: "row", gap: 4 },
  column: { alignItems: "flex-start", flexShrink: 1, gap: 2, maxWidth: "80%" },
  columnRight: { alignItems: "flex-end" },
  deleted: { fontStyle: "italic", opacity: 0.6, padding: 8 },
  footer: { alignItems: "center", flexDirection: "row", gap: 4 },
  footerText: { fontSize: 12, opacity: 0.6 },
  name: { fontSize: 12, fontWeight: "600" },
  row: {
    alignItems: "flex-end",
    flexDirection: "row",
    gap: 8,
    paddingHorizontal: 8,
    paddingVertical: 2,
  },
  rowRight: { justifyContent: "flex-end" },
});
Messages rendered by the custom view, with the sender name above a group and the avatar beside its last message on iOS
Messages rendered by the custom view, with the sender name above a group and the avatar beside its last message on Android

Register it on a WithComponents around your channel and thread screens, or around the whole app:

<WithComponents overrides={messageOverrides}>
  <Channel channel={channel}>
    <MessageList />
    <MessageComposer />
  </Channel>
</WithComponents>

ChatMessage doesn't render MessageHeader, so the marks the default view shows above a message are gone: "Pinned by", the reminder and "Saved For Later" labels, and "Also sent in channel". Render <MessageHeader /> above the name to keep them. The default view also quotes a message when the user swipes it to the right, or to the left in a right-to-left layout. A view of your own doesn't, unless it adds the gesture itself.

Wrapping the default view

Some changes need the default view as it is, with something around it. The SDK's MessageItemView is exported, so a replacement can render it inside a gesture or a container of its own.

Double tap to react

A double tap on a message adds a love reaction, or removes it if the user already gave one. handleReaction from MessageContext is the handler the reaction picker uses: it calls the handleReaction prop of Channel first, toggles the reaction through the channel or the thread, and is undefined on failed messages:

src/DoubleTapMessage.tsx
import { useMemo } from "react";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import {
  MessageItemView,
  useMessageContext,
  useOwnCapabilitiesContext,
} from "stream-chat-react-native";
import type { ComponentOverrides } from "stream-chat-react-native";

const DoubleTapMessage = () => {
  const { handleReaction } = useMessageContext();
  const { sendReaction } = useOwnCapabilitiesContext();

  const doubleTap = useMemo(
    () =>
      Gesture.Tap()
        .numberOfTaps(2)
        .maxDistance(8)
        .runOnJS(true)
        .enabled(!!handleReaction && sendReaction)
        .onEnd((_event, success) => {
          if (success) void handleReaction?.("love");
        }),
    [handleReaction, sendReaction],
  );

  return (
    <GestureDetector gesture={doubleTap}>
      <MessageItemView />
    </GestureDetector>
  );
};

export const doubleTapOverrides: ComponentOverrides = {
  MessageItemView: DoubleTapMessage,
};

runOnJS(true) runs the callbacks on the JavaScript thread, where the handler lives. The gesture is off for users without the send-reaction capability. love is one of the default reaction types; with your own supportedReactions, use a type from your list, or the reaction is saved but no message shows it.

Swipe to delete

The default view quotes a message when the user swipes it. This one instead reveals a Delete button when the user swipes a message they can delete to the left. It uses ReanimatedSwipeable from react-native-gesture-handler, and handleDeleteMessage from useMessageActionHandlers(), which asks for confirmation and then deletes the message:

src/SwipeToDeleteMessage.tsx
import { useCallback } from "react";
import { Pressable, StyleSheet, Text } from "react-native";
import ReanimatedSwipeable from "react-native-gesture-handler/ReanimatedSwipeable";
import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
import {
  MessageItemView,
  useMessageActionHandlers,
  useMessageContext,
  useOwnCapabilitiesContext,
  useTheme,
} from "stream-chat-react-native";
import type { ComponentOverrides } from "stream-chat-react-native";

const SwipeToDeleteMessage = () => {
  const { isMyMessage, message } = useMessageContext();
  const { deleteAnyMessage, deleteOwnMessage } = useOwnCapabilitiesContext();
  const { handleDeleteMessage } = useMessageActionHandlers();
  const {
    theme: { semantics },
  } = useTheme();

  const canDelete =
    message.type !== "deleted" &&
    ((isMyMessage && deleteOwnMessage) || (!isMyMessage && deleteAnyMessage));

  const renderRightActions = useCallback(
    (
      _progress: unknown,
      _translation: unknown,
      swipeable: SwipeableMethods,
    ) => (
      <Pressable
        accessibilityRole="button"
        onPress={() => {
          swipeable.close();
          handleDeleteMessage();
        }}
        style={[styles.action, { backgroundColor: semantics.accentError }]}
      >
        <Text style={styles.actionText}>Delete</Text>
      </Pressable>
    ),
    [handleDeleteMessage, semantics.accentError],
  );

  if (!canDelete) return <MessageItemView />;

  return (
    <ReanimatedSwipeable
      friction={2}
      overshootRight={false}
      renderRightActions={renderRightActions}
    >
      <MessageItemView />
    </ReanimatedSwipeable>
  );
};

export const swipeOverrides: ComponentOverrides = {
  MessageItemView: SwipeToDeleteMessage,
};

const styles = StyleSheet.create({
  action: { justifyContent: "center", paddingHorizontal: 20 },
  actionText: { color: "white", fontWeight: "600" },
});

Pass enableSwipeToReply={false} to Channel and Thread with this override, so the default view's own swipe doesn't compete with the new one. useMessageActionHandlers() returns the handlers Message built for this message, so calling it in every row costs nothing; the other handlers it returns are listed in Actions. The check mirrors the one the SDK uses for its own delete action.

Read more