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:
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.- The paginator makes that interval active:
paginator.state.itemsholds the interval's messages, andpaginator.state.hasMoreHeadistrue, because newer messages exist between that interval and the newest one. - Bob sends a message. It goes to the newest interval, not to the one on screen, so
itemsdoesn't change. - Scrolling down calls
MessagePaginator.toHead(), which loads newer messages. When the two intervals meet, they join. 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 passsuppressFocusSignal: 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:truejumps without highlighting;watchChannel:truealso 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_unreadsets 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, andMessagePaginator.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
pageSizecounts aspageSize; - 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, andfalsewhen 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 becomesreceived. - Failed. If the request fails, the message stays in the paginator with status
failed, and can be retried.Channel.reload()andThread.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, withuploadStateanduploadProgressas 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.