Read-receipt avatars
This UI cookbook shows read receipts in the message list as member avatars: a small avatar under each member's last read message, which moves down as the member reads on. The sent, delivered and read marks next to the time are a different UI, which the Status indicators UI cookbook changes. Where read positions come from is explained in Read state, unread and receipts.
What you'll build
- A small avatar under each member's last read message
- Avatars that move down as members read on
Approach: Replace one part. Choosing where to start explains the approaches.
Best practices
- Read the avatars from
channel.messageReceiptsTracker.snapshotStore. ItsreadersByMessageIdlists each member once, at the message their read position is on, which is where the avatar goes. - Don't use
useMessageReadData()for this. It lists everyone who has read the message, so a member who read the newest message would show under every message above it too. - Leave the user out. The tracker counts the user's own read position as well.
- Keep the default message view and add the row after it, so the bubble, the actions and the status keep working as they do.
- Show the row only in the channel's list. Read positions belong to the channel, not to a thread.
- Subscribe in the avatars row and not in the wrapper around the message view, so a read re-renders the rows and not the messages.
Add the avatars under each message
ReadReceiptAvatars reads the readers of the message it renders under, and MessageWithReadReceipts puts it after the SDK's MessageItemView. The MessageItemView key then renders that pair for every message:
import type { PropsWithChildren } from "react";
import { StyleSheet, View } from "react-native";
import type { MessageReceiptsSnapshot } from "stream-chat";
import {
MessageItemView,
UserAvatar,
useChannel,
useChatContext,
useMessageContext,
useStateStore,
useThreadContext,
WithComponents,
} from "stream-chat-react-native";
// The tracker groups members by the message their read position is on.
const readersSelector = ({ readersByMessageId }: MessageReceiptsSnapshot) => ({
readersByMessageId,
});
/** The avatars of the other members whose last read message is this one. */
const ReadReceiptAvatars = () => {
const channel = useChannel();
const { client } = useChatContext();
const { message } = useMessageContext();
const { thread } = useThreadContext();
const { readersByMessageId } = useStateStore(
channel.messageReceiptsTracker.snapshotStore,
readersSelector,
);
// Read positions belong to the channel's list, so a thread shows none.
if (thread) return null;
const readers = (readersByMessageId[message.id] ?? []).filter(
(user) => user.id !== client.userId,
);
if (!readers.length) return null;
return (
<View style={styles.row}>
{readers.map((user) => (
<UserAvatar key={user.id} size="xs" user={user} />
))}
</View>
);
};
/** The SDK's message view, with the reader avatars under it. */
const MessageWithReadReceipts = () => (
<View>
<MessageItemView />
<ReadReceiptAvatars />
</View>
);
// Declared once, so WithComponents gets the same object on every render.
const overrides = { MessageItemView: MessageWithReadReceipts };
export const WithReadReceiptAvatars = ({ children }: PropsWithChildren) => (
<WithComponents overrides={overrides}>{children}</WithComponents>
);
const styles = StyleSheet.create({
row: {
flexDirection: "row",
gap: 4,
justifyContent: "flex-end",
paddingHorizontal: 16,
paddingVertical: 2,
},
});Wrap the channel screen in WithReadReceiptAvatars:
import type { Channel as StreamChannel } from "stream-chat";
import {
Channel,
MessageComposer,
MessageList,
} from "stream-chat-react-native";
import { WithReadReceiptAvatars } from "./ReadReceiptAvatars";
export const ReadReceiptsChannel = ({
channel,
}: {
channel: StreamChannel;
}) => (
<WithReadReceiptAvatars>
<Channel channel={channel}>
<MessageList />
<MessageComposer />
</Channel>
</WithReadReceiptAvatars>
);![]() | ![]() |
How it works
Example
Three members have read a group chat. One of them stopped at the user's question and the other two read up to the newest message. The first member's avatar sits under the question, and the other two sit under the newest message. When the first member opens the channel and reads on, their avatar moves down to the newest message.
channel.messageReceiptsTrackerkeeps every member's read position: the last message they read, built fromchannel.state.read. ItssnapshotStorepublishesreadersByMessageId, which maps a message id to the members whose position is on that message. A member appears under one message only.ReadReceiptAvatarssubscribes to that store withuseStateStore(), so it re-renders when a member reads further. Their avatar then leaves the old message and shows under the new one.useChannel()returns the channel the message list belongs to, anduseMessageContext()the message being rendered.client.userIdfilters the user out, since the user's own read position is on the newest message they saw.Messagerenders theMessageItemViewkey for each message.MessageWithReadReceiptsrenders the SDK'sMessageItemViewunchanged and adds the row after it. The message menu moves the whole pair, the row included, while it's open.useThreadContext().threadis set inside aThread. Replies have no read positions of their own, so the row isn't rendered there.
The MessageStatus marks of the default view stay on the user's own messages. To show who read a message instead of where each member stopped, see the Message Info sheet in Status indicators.
Read more
- Read state, unread and receipts: read positions and how the SDK turns them into receipts.
- Message receipts tracker:
readersByMessageIdand every method ofchannel.messageReceiptsTracker. - Status indicators: the marks next to the time, and who read a message.
- Avatars:
UserAvatarand its sizes. - Message UI: the
MessageItemViewkey and the other parts of a message. - Custom message: wrapping the default view.

