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

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.

Info:

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.

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.

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 of the latest window. If none is loaded there, 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.
  • 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