Message list UI
The message list is the scrolling part of a channel or a thread. It renders what a message paginator holds and turns the user's scrolling into paginator calls. This page covers the two lists, what they show after a jump, the separators and floating controls, and how one message is put together. The props are in the MessageList and MessageFlashList references.
MessageList and MessageFlashList
Both lists render the messages of the channel or thread they are in:
- Inside a
Thread, they render the thread's replies,thread.messagePaginator, with the parent message above them. - Otherwise they render the channel's messages,
channel.messagePaginator.
So the same <MessageList /> works in both places. Each list subscribes to its paginator's state, and:
- loads older messages when the user scrolls within 100 points of the oldest loaded message;
- loads newer messages when the user scrolls within 100 points of the newest loaded message of an interval that isn't the latest one;
- tells the paginator whether the user is viewing the latest messages: the app is in the foreground, the latest messages are loaded, and the newest one is on screen. While that holds, a new message doesn't count as unread, and in a channel the list marks the channel read when one arrives. Read state, unread and receipts covers the rules.
A load in one direction waits for a load in flight in the other, so the two never shift the scroll position under each other. How the paginator holds intervals of messages is on Message paginators.
The two lists differ in what they're built on:
MessageList |
MessageFlashList |
|
|---|---|---|
| Built on | React Native's FlatList, inverted |
FlashList from @shopify/flash-list, which you install |
| Status | Stable | Experimental: its implementation can change |
| Extra list props | additionalFlatListProps |
additionalFlashListProps |
| Prop for the older end | FooterComponent |
HeaderComponent |
| Prop for the newer end | HeaderComponent |
FooterComponent |
| A list shorter than the screen | Loads older pages until the messages fill the screen | Loads older pages as the user scrolls up |
| Cells | Created for each message | Recycled between messages of the same shape, such as text only or with a gallery |
Use MessageList unless profiling shows the list is the bottleneck. MessageFlashList throws when @shopify/flash-list isn't installed. The list props are named after the list's own ends: MessageList is inverted, so its footer is at the top of the screen.
Jumps on screen
A jump moves the list to one message: a quoted message the user taps, a search result, a pinned message, the first unread message or the latest one. The paginator loads the interval of messages around the target, and the list shows it:
- The interval replaces what was on screen, and once the target is rendered, the list scrolls it to the middle.
- The target is highlighted. The highlight lasts 3 seconds,
DEFAULT_HIGHLIGHT_DURATION, counted from when the list scrolled to it. - If the interval isn't the latest one, the scroll-to-bottom button shows, and scrolling down loads newer messages.
A jump is a paginator call, so you start one the same way from anywhere: MessagePaginator.jumpToMessage(messageId, { focusSignalTtlMs: DEFAULT_HIGHLIGHT_DURATION }) on channel.messagePaginator, or on thread.messagePaginator for a reply. The list reacts to the paginator's messageFocusSignal, even when the jump started before the list mounted. The jump itself is on Message paginators, and the places that start one are on Moving between channels, threads and messages.
Floating controls
Over the list, three elements tell the user where they are:
- The unread notification,
UnreadMessagesNotification, sits at the top of a channel's list. It shows "5 unread" while every message on screen is newer than the last message the user read, so that message is above the screen. Tapping it jumps to the first unread message, and its close button marks the channel read. It doesn't show in a thread, in a channel without read events (unless the channel counts unread messages locally), or while the attachment picker shows the photo grid. - The scroll-to-bottom button,
ScrollToBottomButton, shows while the list isn't at the latest message, or while the latest messages aren't loaded. In a channel, its badge shows the user's unread count from the channel's read state; in a thread, it shows no badge. Tapping it scrolls to the newest message, or jumps to the latest messages when they aren't loaded. In a channel it then marks the channel read, and the unread separator stays where it was. - The sticky date header,
StickyHeader, sits at the top and shows the date of the topmost message on screen. It hides when the oldest message of the channel is on screen, where the inline date separator says the same.hideStickyDateHeaderturns it off.
NetworkDownIndicator shows "Waiting for network..." while the device is offline, and "Reconnecting..." while the connection to Stream is down.
The list also hosts two things that float over it but belong to other parts. The composer's suggestion list renders here, just above the composer, so a screen with a MessageComposer and no message list shows no suggestions. The NotificationList of the channel or thread renders here too; see Displaying notifications.
Separators
Date separators mark where a day starts. An InlineDateSeparator goes above the first message of each day. System messages, such as "Cleo joined the channel", get one only with allowDateSeparatorForSystemMessages. hideDateSeparators turns them off. The separator's date format, and the sticky header's, are translation keys; Localization covers them.
The unread separator, InlineUnreadIndicator, marks where the unread messages start, with "5 new messages". It renders under the last message the user read, from the paginator's unread snapshot: the unread state when the user opened the channel. Opening the channel marks it read but keeps the snapshot, so the user can still see where the new messages start. The snapshot clears when the user closes the unread notification, and when a new message arrives while the user is viewing the latest messages. shouldShowUnreadUnderlay={false} on Channel turns the separator off. Read state and unread counts explains the snapshot.
Messages from the same user, sent one after another, are grouped: the sender's avatar and the timestamp show on the last message of the group. A message that is still sending or that failed keeps its footer, so its status stays visible. enableMessageGroupingByUser={false} on Channel, or noGroupByUser on the list, turns grouping off, and maxTimeBetweenGroupedMessages starts a new group after a gap.
Loading, empty and error states
| State | What the list renders | Override key |
|---|---|---|
| Loading the first page | A spinner with "Loading messages..." in place of the list | MessageListLoadingIndicator |
| Loading older messages | A spinner at the older end. In a thread, under the parent message. | None (the list's prop) |
| Loading newer messages | A spinner at the newer end | None (the list's prop) |
| A channel with no messages | EmptyStateIndicator with listType="message". A thread with no replies shows its parent message instead. |
EmptyStateIndicator |
| The first page failed | Channel or Thread renders LoadingErrorIndicator in place of its children |
LoadingErrorIndicator |
A failed load of older or newer messages is recorded as lastQueryError in the paginator's state, and the list stays as it is. The next scroll to that end tries again.
A message
For each message, the list renders a row: the date separator when a day starts, the message, and the unread separator after the last read message. A system message renders MessageSystem instead of a message.
The message is Message. It creates the message's handlers (react, reply, edit, delete, flag, mute, pin, open the thread, retry a failed send), puts them in MessageContext with the message, along with onLongPress, which opens the message menu. Then it renders the MessageItemView override, by default MessageItemView, with no props. A message view reads everything from useMessageContext().
The default MessageItemView is made of parts, each its own override key:
| Part | Override key |
|---|---|
| The pinned, reminder, saved-for-later and also-sent-in-channel marks above the message | MessageHeader, with MessagePinnedHeader, MessageReminderHeader, MessageSavedForLaterHeader, SentToChannelHeader |
| The sender's avatar, on other users' messages | MessageAuthor |
| The bubble with its content | MessageContent |
| A quoted message | Reply |
| Images and videos, files, other attachments | Gallery, FileAttachmentGroup, Attachment |
| The text of an AI reply as it streams in | StreamingMessageView |
| Reactions, above or below the bubble | ReactionListTop, ReactionListBottom |
| The timestamp, the "Edited" mark and the status | MessageFooter, with MessageTimestamp and MessageStatus |
| The replies button, in a channel | MessageReplies |
| A deleted or failed message | MessageDeleted, MessageError |
A poll renders in the bubble. messageContentOrder on Channel sets the order of the content inside the bubble. The text renders Markdown; markdownRules on Channel changes the rules, and the Message reference covers the parts in detail.
Replacing parts
Register replacements with WithComponents. Overriding components explains how overrides apply.
| Override key | Replaces |
|---|---|
MessageItemView |
A message's look. Gets no props; reads useMessageContext(). |
Message |
A whole message, its handlers included. Gets the message and its place in the list as props. |
| The keys in A message | One part of a message |
MessageSystem |
A system message |
InlineDateSeparator |
The date separators between messages |
StickyHeader, DateHeader |
The sticky date header, and the date label it renders |
InlineUnreadIndicator |
The unread separator |
UnreadMessagesNotification |
The unread notification |
ScrollToBottomButton |
The scroll-to-bottom button. Gets onPress and showNotification. |
TypingIndicator, TypingIndicatorContainer |
The typing indicator and its container |
NetworkDownIndicator |
The connection banner |
EmptyStateIndicator, MessageListLoadingIndicator |
The empty and first-page loading states |
The spinners at the two ends are props of the list, not override keys: pass FooterComponent and HeaderComponent to MessageList, or HeaderComponent and FooterComponent to MessageFlashList. In a thread, the default at the older end is ThreadFooterComponent, which renders the parent message; a replacement there replaces the parent message too. At the newer end, the default also renders the typing indicator, so a replacement there removes it unless it renders one itself.
This list shows each message as one compact line:
import { Pressable, StyleSheet, Text, View } from "react-native";
import {
MessageList,
MessageTextContainer,
useMessageContext,
WithComponents,
} from "stream-chat-react-native";
const CompactMessage = () => {
const { contextMenuAnchorRef, message, onLongPress } = useMessageContext();
return (
// The default view opens the message menu on a long press; a view of your own does it too.
<Pressable
onLongPress={(event) => onLongPress({ emitter: "messageContent", event })}
>
<View ref={contextMenuAnchorRef} style={styles.row}>
<Text style={styles.name}>
{message.user?.name ?? message.user?.id}
</Text>
<MessageTextContainer />
</View>
</Pressable>
);
};
const overrides = { MessageItemView: CompactMessage };
export const CompactMessages = () => (
<WithComponents overrides={overrides}>
<MessageList />
</WithComponents>
);
const styles = StyleSheet.create({
name: { fontWeight: "600" },
row: {
flexDirection: "row",
gap: 8,
paddingHorizontal: 16,
paddingVertical: 4,
},
});MessageTextContainer reads the message from MessageContext too, so it needs no props. The message menu opens from the onLongPress in the context, which Message created. It positions itself on the view in contextMenuAnchorRef. The Custom message UI cookbook builds a complete message view.