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

Message paginators

Every channel and every thread has a message paginator. A channel's messages are in channel.messagePaginator, and a thread's replies are in thread.messagePaginator.

A message paginator is a paginator like any other, as How pagination works describes. It loads the newest messages first, and each later page loads older ones. A channel's paginator loads 100 messages a page, and a thread's 50.

paginator.items holds the messages in the order a message list shows them, oldest first.

This page covers what's particular to message paginators: jumping to a message and the intervals it creates, unread tracking, the default update throttle and the memory cap, and messages that aren't sent yet. Their methods and options are listed in Message paginator.

Intervals after a jump

"Design" has 2,000 messages. When the user opens it, the paginator loads the newest 100. Then they follow a link to a message from last month:

  1. MessagePaginator.jumpToMessage() loads a page around that message. The page is a separate interval, because the messages between it and the newest ones aren't loaded.
  2. The paginator makes that interval active: paginator.state.items holds the interval's messages, and paginator.state.hasMoreHead is true, because newer messages exist between that interval and the newest one.
  3. Bob sends a message. It goes to the newest interval, not to the one on screen, so items doesn't change.
  4. Scrolling down calls MessagePaginator.toHead(), which loads newer messages. When the two intervals meet, they join.
  5. MessagePaginator.jumpToTheLatestMessage() switches back to the newest interval. It needs no request, because that interval is still loaded.

items holds only the interval on screen. The newest message of the channel or thread, whichever interval is shown, is in messagePaginator.aggregateState. A channel list preview reads it there.

Jumping and highlighting

Three methods move the paginator to a message:

  • MessagePaginator.jumpToMessage(messageId) shows the interval that holds the message. A loaded message needs no request. Otherwise the paginator loads a page around it.
  • MessagePaginator.jumpToTheLatestMessage() shows the newest interval. When that interval isn't loaded, it loads the newest page, however far away it is. It doesn't highlight anything unless you pass suppressFocusSignal: false.
  • MessagePaginator.jumpToTheFirstUnreadMessage() goes to the first message the user hasn't read, as the next section explains.

Each one resolves to true when it got there and false when it didn't. When MessagePaginator.jumpToMessage() or MessagePaginator.jumpToTheLatestMessage() fails, it also publishes an error notification through client.notifications. A jump that a later jump or reload overtakes resolves to false without a notification: its page stays loaded but isn't shown.

They take the same options:

  • pageSize: how many messages to load around the target;
  • focusReason: the reason the signal carries, "jump-to-message" by default. The other two methods set their own, "jump-to-latest" and "jump-to-first-unread";
  • focusSignalTtlMs: how long the highlight lasts once the message is on screen, 3,000 ms by default;
  • suppressFocusSignal: true jumps without highlighting;
  • watchChannel: true also watches the channel, with the same request, if it isn't watched yet.

After a jump, the messagePaginator.messageFocusSignal store says which message to highlight: { signal: { messageId, reason, token, createdAt, ttlMs } }. The token changes on every jump, so jumping twice to the same message gives two signals. The signal stays until it's cleared. Once the message is on screen, call MessagePaginator.scheduleMessageFocusSignalClear({ token }), and the signal clears after ttlMs:

const { messagePaginator } = channel;

const highlight = (messageId: string) => {
  document.getElementById(messageId)?.classList.add("highlighted");
};

const unsubscribe = messagePaginator.messageFocusSignal.subscribeWithSelector(
  ({ signal }) => ({ signal }),
  ({ signal }) => {
    if (!signal) return;
    highlight(signal.messageId);
    // The message is on screen: start counting the highlight's lifetime.
    messagePaginator.scheduleMessageFocusSignalClear({ token: signal.token });
  },
);

await messagePaginator.jumpToMessage(messageId);

MessagePaginator.clearMessageFocusSignal() clears the signal at once, for example when the user scrolls away before the highlight ends. Pass the signal's token when the clear can run late: MessagePaginator.clearMessageFocusSignal({ token }) does nothing if another jump has replaced the signal in the meantime.

Live view and unread

The state layer has no viewport. A UI that knows the user is looking at the newest messages says so with MessagePaginator.setViewingLive(true), and with false when they scroll away or the app goes to the background. The UI layer's message lists do this for you. The liveViewState store holds the value, { isViewingLive }, which is false until something sets it. While a channel's list has it true, a new message in the channel doesn't count as unread for the user, because the UI is about to mark it read. A thread's list holds the value too, but a thread counts unread replies by whether the thread is active instead.

Unread state has a second problem. The user opens "Design" with 12 unread messages, and the channel is marked read as it opens. Their unread count in read state drops to 0, but the UI still needs to show where they left off.

That's what the unread snapshot is for. The unreadStateSnapshot store keeps a copy of the user's unread state, taken when the paginator loads its first page:

Field What it holds
lastReadAt When the user last read the channel, in nanoseconds, or null.
lastReadMessageId The last message the user read, or null.
firstUnreadMessageId The message the user marked unread from, or null.
unreadCount How many messages were unread.

Marking the channel read doesn't change the snapshot. The UI clears it with MessagePaginator.clearUnreadSnapshot() once the user has caught up. These change it too:

  • a new message that counts as unread raises unreadCount;
  • notification.mark_unread sets every field from the user's new read state;
  • truncating the channel clears it;
  • MessagePaginator.seedUnreadSnapshot() takes it again from the current read state, for example when a channel opens again without loading, and MessagePaginator.setUnreadSnapshot() changes some fields.

MessagePaginator.jumpToTheFirstUnreadMessage() reads the snapshot first. It goes to firstUnreadMessageId when there is one. Otherwise it goes to the first message after lastReadAt, loading a page around that time if needed. As a last resort it goes to the last read message.

To make MessagePaginator.jumpToTheFirstUnreadMessage() read channel.state.read instead of the snapshot, set unreadReferencePolicy to "read-state-only". It's read when a channel or thread is created, so register it before:

client.config.set({
  messagePaginator: { unreadReferencePolicy: "read-state-only" },
});

Throttling and the memory cap

A busy channel can get dozens of events a second: new messages, reactions, read receipts. A paginator that published every one would make the UI render as often. So a message paginator publishes items at most once every 500 ms, and a burst of events causes about two updates a second.

Not everything waits:

  • loading pages and jumping publish at once;
  • changes the user makes through message operations, such as sending, editing or reacting, publish at once.

The stateThrottleMs setting sets that wait, 500 ms by default. 0 turns the throttle off, so every change publishes at once.

A long session in a busy channel also keeps adding messages to memory. maxLoadedItems caps the newest interval: once it holds more messages than the cap, the oldest ones are dropped as new ones arrive. hasMoreTail becomes true again, so scrolling back loads them again. The cap is off by default. It works like this:

  • it applies only to the newest interval, while that interval is on screen;
  • a cap smaller than pageSize counts as pageSize;
  • unsent messages are never dropped;
  • a UI can pause it while the user reads near the oldest loaded message, with MessagePaginator.setPruningSuspended(true), so messages don't disappear under them.

The cap suits long sessions in busy channels where the user mostly follows the newest messages, such as a livestream. It holds only while the user stays there:

  • a jump to an older message leaves the newest interval inactive, so it isn't pruned and keeps growing until the user returns, and the interval jumped to isn't capped either;
  • pruning drops the oldest loaded messages when a new one arrives, including pages the user just scrolled back to. Call MessagePaginator.setPruningSuspended(true) while the user reads near the oldest loaded message, and false when they return. The SDK's message lists don't do this for you.

These settings go in client.config. The messagePaginator key applies to channels and threads, and the messagePaginator inside channel or thread overrides it for one of them:

client.config.set({
  // every message paginator
  messagePaginator: { stateThrottleMs: 300 },
  // channels only: a livestream keeps at most 500 messages
  channel: { messagePaginator: { maxLoadedItems: 500 } },
});

Unsent messages

A message paginator also holds the user's messages that the server hasn't confirmed yet:

  • Sending. A message the user sends appears at once with status sending, without waiting for the throttle. When the server answers, its copy replaces the local one and the status becomes received.
  • Failed. If the request fails, the message stays in the paginator with status failed, and can be retried. Channel.reload() and Thread.reload() keep failed messages.
  • Waiting for uploads. With sending with pending uploads on, a message is sent before its attachments finish uploading. It shows at once with its local files. Each attachment keeps its localMetadata, with uploadState and uploadProgress as they were when the user sent the message. When the uploads finish, the message goes out. If one fails, the message fails.

The memory cap never drops these messages, and they never mark where older pages start loading.

Message operations explains sending and retrying. What's optimistic and what isn't lists what each message action shows before the server answers.

Used in