feat: optimistic reactions - #1808
Merged
Merged
Conversation
Support setting paginator items directly, optional request retries, offline DB in ChannelPaginator, identification of pagination restart based on query shape change.
…has_unread, last_updated
…n other matching paginators
…n other matching paginators
…eat/message-paginator # Conflicts: # src/channel.ts # src/client.ts
… and message.updated
…or itemOrderComparator
….messages.deleted and message.deleted events
## CLA - [ ] I have signed the [Stream CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform) (required). - [ ] Code changes are tested ## Description of the changes, What, Why and How? ## Changelog -
…ith paginators (#1806) ### Summary Completes the `message-paginator` initiative on the LLC side: the channel's **messages, thread replies, and pinned messages are no longer stored on `channel.state`**. Each list now lives in a paginator that is the single source of truth (interval storage + a canonical `ItemIndex`): - **Main message list** → `channel.messagePaginator` - **Thread replies** → `thread.messagePaginator` (owned by the `Thread` object) - **Pinned messages** → `channel.pinnedMessagesPaginator` (new `PinnedMessagePaginator`) ### What changed - **`ChannelState` storage removed:** `messages`, `latestMessages`, `messageSets`, `messagePagination`, `threads`, `pinnedMessages`, and their mutators (`addMessageSorted`/`addMessagesSorted`, `removeMessage`, `findMessage`, `findMessageByTimestamp`, `filterErrorMessages`, `loadMessageIntoState`, `clearMessages`, `initMessages`, `pruneOldest`, `addReaction`/`removeReaction`, `updateUserMessages`, `deleteUserMessages`, `addPinnedMessages`/`addPinnedMessage`/`removePinnedMessage`, `removeQuotedMessageReferences`, + internal helpers). - **`PinnedMessagePaginator`** added and populated from channel events; pin/unpin falls out of `ingestItem` + a `{ pinned: true }` filter (no bespoke branching). - **`MessageIntervalPaginator`** extracted as the unread-free base of `MessagePaginator`; it tracks the latest message (`latestMessageId` + `latestMessage`), advanced on ingest and mirrored into reactive state. - **`channel.state.last_message_at`** is now a **read-only getter** derived from `messagePaginator.latestMessage` (setter + backing field removed). - **`channel.state.isUpToDate` / `setIsUpToDate` removed** — live-message routing (don't disrupt a scrolled-away view) is handled structurally by the paginator's interval model. - **`Channel._trackLatestMessage` removed.** `user.updated` / `user.deleted` propagation now scans active channels (`reflectUserUpdate` / `applyMessageDeletionForUser` self-filter by author id). A targeted user-reference index is planned (`specs/user-reference-index`). - **`utils` cleanup:** removed `addToMessageList`, `messageSetPagination`, `binarySearchByDateEqualOrNearestGreater`, `deleteUserMessages`, and the `MessageSet` / message-set pagination types. - **Reactive `headItems`** added to `PaginatorState` (newest-loaded window). - **Removed the unused `MessageReplyPaginator`** (dead sibling; the thread reply list uses `MessagePaginator`). ### Breaking changes Full list + before → after migration table in **`docs/breaking-changes-v14-v15.md`**. Highlights: | Before (v14) | After (v15) | | --- | --- | | `channel.state.messages` | `channel.messagePaginator.state.items` / `.items` | | `channel.state.threads[parentId]` | `thread.messagePaginator.state.items` | | `channel.state.pinnedMessages` | `channel.pinnedMessagesPaginator.state.items` | | `channel.state.addMessageSorted(m)` | `channel.messagePaginator.ingestItem(m)` | | `channel.state.removeMessage({ id })` | `channel.messagePaginator.removeItem({ id })` | | `channel.state.findMessage(id)` | `channel.messagePaginator.getItem(id)` | | `channel.state.isUpToDate` / `setIsUpToDate` | `messagePaginator.isActiveIntervalAtHead` / `hasMoreHead` / `jumpToTheLatestMessage()` | | `channel.state.last_message_at = …` | read-only (derived); ingest a message on the paginator | Behavioral note: `last_message_at` now advances on every incoming message regardless of the viewer's scroll position (the old `isUpToDate` suppression is gone) — it's a channel-level fact. ### Follow-ups (not in this PR) - **`specs/user-reference-index`** — replace the active-channel scan on `user.updated`/`user.deleted` with a `userId → message-reference` index (design-gated). - Unifying the remaining flat paginators on interval storage - Offline migration --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
isekovanic
requested review from
MartinCupela,
arnautov-anton,
oliverlaz,
santhoshvai,
szuperaz and
vishalnarkhede
as code owners
July 24, 2026 09:06
Contributor
|
Size Change: +7.79 kB (+1.53%) Total Size: 517 kB 📦 View Changed
|
…o feat/message-paginator-master-merge # Conflicts: # src/channel.ts # src/channel_state.ts # src/client.ts # src/messageComposer/messageComposer.ts # src/messageComposer/middleware/textComposer/types.ts # src/messageComposer/textComposer.ts # src/messageDelivery/MessageDeliveryReporter.ts # src/messageDelivery/MessageReceiptsTracker.ts # src/moderation.ts # src/pagination/BasePaginator.ts # src/pagination/ReminderPaginator.ts # src/pagination/UserGroupPaginator.ts # src/signing.ts # src/thread.ts # src/types.ts # src/utils.ts # test/unit/CooldownTimer.test.ts # test/unit/MessageComposer/messageComposer.test.ts # test/unit/MessageComposer/middleware/messageComposer/cleanData.test.ts # test/unit/MessageComposer/textComposer.test.ts # test/unit/channel.test.js # test/unit/channel_state.test.js # test/unit/client.test.js # test/unit/messageDelivery/MessageDeliveryReporter.test.ts # test/unit/messageDelivery/MessageReceiptsTracker.test.ts # test/unit/pagination/UserGroupPaginator.test.ts # test/unit/threads.test.ts # test/unit/utils.test.js # test/unit/utils.test.ts
## CLA - [ ] I have signed the [Stream CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform) (required). - [ ] Code changes are tested ## Description of the changes, What, Why and How? ## Changelog -
Base automatically changed from
feat/message-paginator-master-merge
to
release-v10
August 3, 2026 09:40
MartinCupela
approved these changes
Aug 5, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
CLA
Description of the changes, What, Why and How?
Builds on the message paginator state layer with three related capabilities: a client-global reactive message store, optimistic reactions built on top of it and state-publish throttling for the message list.
1. Reactive collections - a client-global message store
Separate PR reference: #1811
TLDR:
A new
MessageStore(client.messageStore) that holds exactly one canonicalLocalMessageper id, plus aStoreBackedItemIndexthat lets paginators keep message content in the shared store while membership stays local.Previously every collection (channel message list, thread replies, thread parent) held its own copy of a message. A message shared across collections - e.g. a
show_in_channelreply that lives in both the channel list and its thread - meant an edit/reaction had to be manually fanned out to each copy and copies drifted. There was no single source of truth for a message's content.A paginator sees the same CRUD surface as a plain
ItemIndex, butgetItem(id)still means "does this paginator hold the id" while the object lives once in the store. Writing content links the writer as a holder (drives notification fanout + refcount GC) and notifies every other holder, which reprojects. Removal unlinks a holder rather than hard-deleting, so a message still held elsewhere survives and the store GCs it only when the last holder unlinks. Subscribers are notified at most once per transaction with just the subset of their watched ids that changed.2. Optimistic reactions
Channel.addReactionWithLocalUpdate/deleteReactionWithLocalUpdateand theThreadequivalents cause reactions to render instantly and stay consistent across every collection holding the message.Reactions previously waited on the server round trip and a reaction on a message held in multiple places (notably the thread parent, which lives in no paginator) had no single place to apply it consistently.
applyReactionLocallyaddresses the message purely by id in the store, folds the reaction intoreaction_groups/latest_reactions(shared count helperscomputeOwnReactions/messageWithReactionAdded/messageWithReactionRemoved, so paginator and paginator less thread parent compute identically) and writes it back and the store then reprojects every holder. It writes memory synchronously and the offline DB and returns anundo()that durably reverses both on a terminal server failure (respectingisEphemeral, so transient/offline failures stay queued rather than rolling back). The lower-levelChannel.sendReaction/deleteReactiononly queue the offline replay task; persistence of the optimistic row is owned byapplyReactionLocally.3. State publish throttling
A shared
throttle()utility and a global switch (stateThrottling) that throttles the message liststatepublish inBasePaginator.High frequency live mutations (rapid events, receipts) would publish to
stateon every change; coalescing the window publish cuts redundant rerenders - while a user's own optimistic action must still render immediately.throttle()(leading/trailing, returning{ throttledFn, cancelTimer, flush }, ported from the Feeds client and extended withflush()) coalesces publishes per window. Optimistic writes bypass the delay via the store'sflushState()hook.Changelog
client.messageStore(MessageStore) - a client-global, normalized, reactive store holding one canonical message per id; exportedMessageStore,MessageStoreChangeBatch,MessageStoreSubscriber.Channel.addReactionWithLocalUpdate/Channel.deleteReactionWithLocalUpdateandThread.addReactionWithLocalUpdate/Thread.deleteReactionWithLocalUpdate, with durable rollback across memory + offline DB.show_in_channelreplies alive while any holder remains.statepublish (throttleutility +stateThrottlingswitch); local/optimistic writes bypass the throttle; disabled under test runners.