Message receipts tracker
channel.messageReceiptsTracker answers two questions about a message in a channel: who has received it, and who has read it. It's a MessageReceiptsTracker from stream-chat, and this page lists its API.
How receipts work end to end, with delivery reports and a "seen by" status, is on Read state, unread and receipts.
Getting the tracker
Every Channel creates its tracker when it's constructed, and starts it at once: channel.messageReceiptsTracker. The tracker stops when the channel is disposed. Don't construct one yourself.
Who received and who read a message
The tracker keeps one record per member, built from the channel's read state, channel.state.read:
- the last message the member read;
- the last message delivered to the member.
Delivered only moves forward and is never behind read. Read moves forward too, except when the current user marks a message unread. So a member has read every message up to their read position, and received every message up to their delivered position. The tracker answers for any message by comparing its timestamp with those two positions.
Example
Take the user's messages "Lunch?" at 12:00 and "At 1?" at 12:01, in a channel with Bob and Cleo. Bob has read both. Cleo has received both and read only "Lunch?":
| Question | Answer, leaving out the user |
|---|---|
readersForMessage for "Lunch?" |
Bob, Cleo |
readersForMessage for "At 1?" |
Bob |
deliveredForMessage for "At 1?" |
Bob, Cleo |
deliveredNotReadForMessage for "At 1?" |
Cleo |
The user's own record is tracked too. Sending a message moves the sender's read position to it, so the user appears among the readers of their own messages. Filter out client.userId when you show the lists.
Staying current
The tracker follows channel.state.read. Every SDK write to it updates the tracker: a channel query, and the message.read, notification.mark_read, notification.mark_unread, message.delivered and message.new events, among others. App code never feeds the tracker. With offline support, a channel loaded from the local database brings the read state stored with it, so the tracker answers before the server does.
After each change the tracker publishes a new value to snapshotStore. Read the tracker's methods after a change, and subscribe to snapshotStore to know when one happened:
const tracker = channel.messageReceiptsTracker;
const ref = { msgId: message.id, timestamp: message.created_at };
const unsubscribe = tracker.snapshotStore.subscribeWithSelector(
({ revision }) => ({ revision }),
() => {
const readers = tracker
.readersForMessage(ref)
.filter((user) => user.id !== client.userId);
console.log(
`Read by ${readers.map((user) => user.name ?? user.id).join(", ")}`,
);
},
);snapshotStore holds a MessageReceiptsSnapshot:
| Field | Type | What it holds |
|---|---|---|
revision |
number |
Grows by one on every change. Select it to re-render on any change. |
readersByMessageId |
Record<string, UserResponse[]> |
The users whose read position is exactly that message. A user appears once, at the message they stopped on. |
deliveredByMessageId |
Record<string, UserResponse[]> |
The same for delivered positions. |
lastReadRefByOthers |
MsgRef or null |
The furthest read position of any member other than the current user, or null when nobody has read yet. |
lastDeliveredRefByOthers |
MsgRef or null |
The same for delivered positions. |
readersByMessageId doesn't say who read a message: a reader of a later message has read this one too. Ask readersForMessage for that, or add up the buckets from the newest message down.
In a component
stream-chat-react-native exports hooks that read the tracker of the channel in ChannelContext. Each takes { message } and re-renders when snapshotStore changes:
| Hook | Returns |
|---|---|
useMessageReadData({ message }) |
readersForMessage() for the message, the current user included. |
useMessageDeliveredData({ message }) |
deliveredForMessage() for the message, the current user included. |
useIsMessageReadByOthers({ message }) |
Whether the message is at or before lastReadRefByOthers. |
useIsMessageDeliveredToOthers({ message }) |
Whether the message is at or before lastDeliveredRefByOthers. |
MessageStatus uses the last two. useMessageDeliveryStatus reads the same store for the delivery status in channel and thread list previews.
Methods
A message is passed as a MsgRef, { msgId: string; timestamp: TimestampNS }. Use the message's id and created_at. The tracker compares messages by timestamp.
| Method | Returns | What it answers |
|---|---|---|
readersForMessage(msgRef) |
UserResponse[] |
Who has read the message: everyone whose read position is at it or later. |
deliveredForMessage(msgRef) |
UserResponse[] |
Who has received it, readers included. |
deliveredNotReadForMessage(msgRef) |
UserResponse[] |
Who has received it and not read it. |
usersWhoseLastReadIs(msgRef) |
UserResponse[] |
Whose read position is exactly msgRef. Matches on msgId and on timestamp, see below. |
usersWhoseLastDeliveredIs(msgRef) |
UserResponse[] |
The same for delivered positions. |
hasUserRead(msgRef, userId) |
boolean |
Whether that user has read the message. |
hasUserDelivered(msgRef, userId) |
boolean |
Whether the message was delivered to that user. |
isReadByOthers(msgRef) |
boolean |
Whether any member other than the current user has read it. |
isDeliveredToOthers(msgRef) |
boolean |
Whether it was delivered to any member other than the current user. |
getUserProgress(userId) |
UserProgress or null |
The user's record: { user, lastReadRef, lastDeliveredRef }, or null for a user the tracker doesn't know. |
groupUsersByLastReadMessage() |
Record<string, UserResponse[]> |
The users grouped by the message their read position is on. The same map as readersByMessageId. |
groupUsersByLastDeliveredMessage() |
Record<string, UserResponse[]> |
The same for delivered positions. |
In a UserProgress, a position's timestamp is the time of the read or the delivery, not the message's created_at. A position that's unknown has the timestamp -Infinity and an empty msgId. So MessageReceiptsTracker.usersWhoseLastReadIs() and MessageReceiptsTracker.usersWhoseLastDeliveredIs() with a ref built from the message usually find nobody: to list who stopped at a message, read readersByMessageId[msgId] or deliveredByMessageId[msgId], which match on the id alone.
isReadByOthers and isDeliveredToOthers read the snapshot's furthest positions, so an older message never looks less far along than a newer one. They suit a single "sent, delivered, read" status. The lists suit "seen by" details.
The tracker also has methods that write its records: MessageReceiptsTracker.ingestInitial(), MessageReceiptsTracker.onMessageRead(), MessageReceiptsTracker.onMessageDelivered(), MessageReceiptsTracker.onNotificationMarkUnread(), MessageReceiptsTracker.reconcileFromReadStore() and MessageReceiptsTracker.setPendingReadStoreReconcileMeta(). Its MessageReceiptsTracker.registerSubscriptions() and MessageReceiptsTracker.unregisterSubscriptions() start and stop following the read state. The channel calls what it needs and keeps the tracker in step with its read state by itself. Called from app code, the writing methods put the tracker out of step with channel.state.read until the next change.
Limits
- Only loaded messages resolve. When the server reports a read or delivered position with a message id, the tracker uses it. When it reports only a time, the tracker looks for the last message at or before that time among the loaded messages, newest interval first. If no loaded message is that old, the position stays unknown, so that member counts as having read or received nothing.
- Channels only, for now. Threads have no tracker of their own, read events for a thread don't move the channel's read positions, and delivery isn't reported for thread replies. Inside a
Thread, the hooks above read the channel's tracker. - Delivered needs the server. Delivery events are off by default for a channel type. With them off, every delivered position follows the read position. See Message delivery and read status.
See also
- Read state, unread and receipts shows the feature end to end, including delivery reports and the settings that turn them off.
- Read state and unread counts explains
channel.state.readand the events that change it. - Channel and channel state lists the fields of each read state entry.