Read state, unread and receipts
This page covers what a user knows about reading: which channels have unread messages, where the unread part of a channel starts, and whether the people in a channel have received and read the user's own messages.
What users get
- Unread counts. Each row in the channel list shows how many messages the user hasn't read. A muted channel shows none.
- Where unread starts. The user opens a channel with three new messages. A separator reading "3 unread" sits above the first of them. While the separator is out of view, a floating "3 unread" button jumps back to it. The × on either one marks the channel read.
- New messages while scrolled up. When messages arrive while the user reads older ones, a "new messages" notification and the scroll-to-latest button take them down.
- Marking read. The channel is marked read when the user reaches the latest message with the tab in front. Mark as unread in the menu of someone else's message marks the channel unread from that message on.
- Receipts. The user's own messages show their status next to the timestamp: a clock while sending, one check once sent, two checks once delivered, and two checks in the accent color once read, with the number of readers when more than one has read it. Hovering shows who read it. By default only their latest message shows sent, delivered or read; the clock shows on every message that is sending. The channel list row shows the status of their last message too.
How it works across the layers
- State.
channel.state.readholds each member's read position: when they last read (last_read), the last message they read, their unread count, and the last message delivered to them.message.read,notification.mark_read,notification.mark_unread,message.newandmessage.deliveredevents update it.Channel.markRead()andChannel.markUnread({ message_id })move the user's own position. Both need read events on, and the state changes when the server's event arrives.- The message list keeps an unread snapshot,
messagePaginator.unreadStateSnapshot: where the user's unread part started when the channel opened. Marking the channel read doesn't move it, so the separator stays where the user left off.MessagePaginator.jumpToTheFirstUnreadMessage()loads that part and jumps to it. channel.messageReceiptsTrackerturns the read positions into receipts, see Receipts.
- UI.
MessageListandVirtualizedMessageListmark the channel read when the user is at the latest message, the tab is visible and no newer messages wait beyond the loaded ones. The calls go throughclient.messageDeliveryReporter.throttledMarkRead, at most one a second. While the user is there, new messages don't raise the unread count, so the separator and the count don't flash before the list marks them read. After Mark as unread, the lists don't mark the channel read again until the user dismisses the separator or opens the channel again.- The lists render
UnreadMessagesSeparatorabove the first unread message andUnreadMessagesNotificationwhile the separator is out of view, both from the unread snapshot. The notification's main button callsMessagePaginator.jumpToTheFirstUnreadMessage(). Thread lists show no unread notification. NewMessageNotificationandScrollToLatestMessageButtonhandle messages that arrive while the user is scrolled up.MessageStatusrenders the receipts on the user's own messages, andChannelListItemrenders the unread count and the last message's status.
- Layout. Not involved.
Read state and unread counts explains the read state in full: what counts as unread, every way to mark read and unread, and the differently shaped read state of threads. Message paginators covers jumps and the unread snapshot.
Receipts
A receipt answers two questions about one of the user's messages: who has received it, and who has read it. Receipts are worked out for channels only, for now.
Who received and read a message
channel.messageReceiptsTracker keeps, for each member, the last message delivered to them and the last message they read. Read and delivered only move forward, so a member has read every message up to their read position.
Example
Take the user's messages "Lunch?" at 12:00 and "At 1?" at 12:01, in a channel with Bob and Cleo:
- Bob's and Cleo's devices receive both messages and report them delivered. The tracker moves both delivered positions to "At 1?". The user sees two checks on "At 1?".
- Bob opens the channel and reads both. His read position moves to "At 1?". The user sees the read checks, and the tooltip says Bob.
- Cleo reads only up to "Lunch?".
MessageReceiptsTracker.readersForMessage()for "Lunch?" returns Bob and Cleo; for "At 1?" it returns Bob.
The tracker answers per message, given its id and created_at:
MessageReceiptsTracker.readersForMessage(ref)andMessageReceiptsTracker.deliveredForMessage(ref)return the users who read or received it;MessageReceiptsTracker.deliveredNotReadForMessage(ref)returns those who received it and haven't read it;MessageReceiptsTracker.hasUserRead(ref, userId)andMessageReceiptsTracker.hasUserDelivered(ref, userId)answer for one user;MessageReceiptsTracker.getUserProgress(userId)returns one user's positions.
The lists can include the user's own read position, so filter out client.user.id when you show them. The tracker publishes a snapshot to snapshotStore on every change; subscribe to it to re-render. Message receipts tracker lists every method.
Delivery reports
The client reports delivery for the user by itself. When messages arrive, or a channel loads, client.messageDeliveryReporter notes the newest message the user hasn't read or confirmed yet in that channel. It waits a second, then sends the notes for all channels in one request, up to 100 channels at a time. The server then sends message.delivered to the channel, and every member's tracker moves.
The client sends no report:
- for a channel with delivery events off, see Turning it on or off;
- when the user's
privacy_settings.delivery_receipts.enabledisfalse; - for messages the user has already read;
- for thread replies.
In the client configuration, client.messageDelivery.markAsDeliveredBufferTimeoutMs changes the one-second wait, and client.messageDelivery.maxDeliveredMessageCountInPayload the 100.
Limits
- Channels only, for now. Threads have no tracker, delivery isn't reported for thread replies, and inside a thread
MessageStatusshows only the sending clock. - Only loaded messages resolve. When the server reports a read or delivered position without a message id, the tracker looks for the message at that time among the loaded messages. If that message isn't loaded, the position is skipped.
- Delivered needs the server. On the server, delivery events are off by default for a channel type; see Message delivery and read status to turn them on.
Turning it on or off
Each switch has a client side and a server side, and a server "no" wins. The resolved values are in channel.config.
| Switch | What turning it off does |
|---|---|
channel.readEvents.enabled (client), read_events (server) |
No read state is sent. The lists stop marking the channel read, and Channel.markRead() and Channel.markUnread() throw. |
channel.readEvents.localUnreadCountEnabled (client) |
Off by default. On, it keeps an unread count on the device while read events are off, and the lists reset it with Channel.markReadLocally() instead of a request. |
channel.deliveryEvents.enabled (client), delivery_events (server) |
No delivery reports for those channels. Messages go from sent to read. |
The user's privacy_settings.read_receipts.enabled |
The server doesn't tell other members when this user reads. |
The user's privacy_settings.delivery_receipts.enabled |
The client doesn't report delivery for this user, and the server doesn't show it to others. |
Mark as unread also needs the read-events capability, which the server grants per channel.
To turn delivery reports off for every channel:
client.config.set({ channel: { deliveryEvents: { enabled: false } } });A user sets their own privacy settings when connecting:
import { useCreateChatClient } from "stream-chat-react";
export const useClientWithPrivateReceipts = () =>
useCreateChatClient({
apiKey,
tokenOrProvider: userToken,
userData: {
id: userId,
privacy_settings: {
delivery_receipts: { enabled: false },
read_receipts: { enabled: false },
},
},
});Configuration explains client.config, and the privacy settings section of the JavaScript client docs covers the user side.
Customizing
A status on every message
MessageStatus shows on the latest own message only. Pass returnAllReadData to the list to show it on every own message. This example also replaces the read status with the readers' names:
import type { Channel as ChannelType } from "stream-chat";
import {
Channel,
MessageList,
MessageStatus,
WithComponents,
useChatContext,
useMessageContext,
type MessageStatusProps,
} from "stream-chat-react";
const SeenBy = () => {
const { client } = useChatContext();
const { readBy = [] } = useMessageContext();
const names = readBy
.filter((user) => user.id !== client.user?.id)
.map((user) => user.name || user.id);
return <span className="seen-by">Seen by {names.join(", ")}</span>;
};
const CustomMessageStatus = (props: MessageStatusProps) => (
<MessageStatus {...props} MessageReadStatus={SeenBy} />
);
export const ChannelWithSeenBy = ({ channel }: { channel: ChannelType }) => (
<WithComponents overrides={{ MessageStatus: CustomMessageStatus }}>
<Channel channel={channel}>
<MessageList returnAllReadData />
</Channel>
</WithComponents>
);MessageStatus also takes MessageSendingStatus, MessageSentStatus and MessageDeliveredStatus for the other states.
Receipts in your own components
Ask the tracker directly, and subscribe to its snapshot so the component re-renders when someone's position moves. Place it inside Channel:
import type { LocalMessage, MessageReceiptsSnapshot } from "stream-chat";
import { useChannel, useChatContext, useStateStore } from "stream-chat-react";
const revisionSelector = ({ revision }: MessageReceiptsSnapshot) => ({
revision,
});
export const ReadByCount = ({ message }: { message: LocalMessage }) => {
const { client } = useChatContext();
const channel = useChannel();
const tracker = channel.messageReceiptsTracker;
// Re-renders whenever a read or delivered position moves
useStateStore(tracker.snapshotStore, revisionSelector);
const ref = { msgId: message.id, timestamp: message.created_at };
const readers = tracker
.readersForMessage(ref)
.filter((user) => user.id !== client.user?.id);
const waiting = tracker.deliveredNotReadForMessage(ref).length;
return (
<span>
Read by {readers.length}, delivered to {waiting} more
</span>
);
};The unread separator and notification
Replace them through WithComponents. The separator receives the unread count:
import type { Channel as ChannelType } from "stream-chat";
import {
Channel,
UnreadMessagesNotification,
WithComponents,
type UnreadMessagesNotificationProps,
type UnreadMessagesSeparatorProps,
} from "stream-chat-react";
const CustomUnreadMessagesSeparator = ({
unreadCount,
}: UnreadMessagesSeparatorProps) => (
<div className="custom-unread-separator">
{unreadCount ? `${unreadCount} new messages` : "New messages"}
</div>
);
// Keeps the default notification, and loads 50 messages when it jumps
const CustomUnreadMessagesNotification = (
props: UnreadMessagesNotificationProps,
) => <UnreadMessagesNotification {...props} queryMessageLimit={50} />;
export const ChannelWithCustomUnread = ({
channel,
}: {
channel: ChannelType;
}) => (
<WithComponents
overrides={{
UnreadMessagesNotification: CustomUnreadMessagesNotification,
UnreadMessagesSeparator: CustomUnreadMessagesSeparator,
}}
>
<Channel channel={channel}>{/* MessageList, MessageComposer */}</Channel>
</WithComponents>
);The default separator and notification show the count unless you pass showCount={false}, and both have a button that marks the channel read. A custom separator without that button leaves marking read to the list.
To jump from your own button, call channel.messagePaginator.jumpToTheFirstUnreadMessage(). A thread's messagePaginator has the same method, but it takes the unread position from the channel's read state, not the thread's. Message list UI explains the separators and what the list shows after a jump.
Reference
- Message receipts tracker: every method of
channel.messageReceiptsTracker. - Channel and channel state:
channel.state.readand the mark-read methods. - Message paginator:
unreadStateSnapshotandjumpToTheFirstUnreadMessage. - Message UI components: the
MessageStatusprops. - MessageList and VirtualizedMessageList:
returnAllReadDataandshowUnreadNotificationAlways. - ComponentContext: the
UnreadMessagesSeparator,UnreadMessagesNotification,NewMessageNotificationandMessageStatusoverride keys.