From e2db8b3ab81f99526b08548d9f3df0e80d5436d5 Mon Sep 17 00:00:00 2001 From: martincupela Date: Mon, 20 Jul 2026 12:33:48 +0200 Subject: [PATCH 01/25] refactor(channel): migrate readers and reactions to messagePaginator Checkpoint before deleting legacy ChannelState message-list + threads storage. Covers Tasks 8-10 and Task 11 steps 1-2; Task 11 step 3+ follows. Co-Authored-By: Claude Opus 4.8 --- .../decisions.md | 41 ++ .../plan.md | 286 +++++++++ .../spec.md | 72 +++ .../state.json | 82 +++ .../decisions.md | 62 ++ .../plan.md | 572 ++++++++++++++++++ .../spec.md | 85 +++ .../state.json | 156 +++++ specs/retained-items/spec.md | 174 ++++++ src/CooldownTimer.ts | 2 +- src/channel.ts | 100 ++- src/channel_state.ts | 112 ---- src/client.ts | 59 +- .../MessageDeliveryReporter.ts | 2 +- src/messageDelivery/MessageReceiptsTracker.ts | 2 +- src/pagination/paginators/BasePaginator.ts | 44 +- src/pagination/paginators/MessagePaginator.ts | 271 ++++++++- src/pagination/utility.search.ts | 24 + test/unit/CooldownTimer.test.ts | 44 +- test/unit/channel.test.js | 158 +++-- test/unit/channel_state.test.js | 270 +-------- .../MessageDeliveryReporter.test.ts | 54 +- .../MessageReceiptsTracker.test.ts | 6 +- .../paginators/MessagePaginator.test.ts | 432 +++++++++++++ test/unit/pagination/utility.search.test.ts | 29 + 25 files changed, 2600 insertions(+), 539 deletions(-) create mode 100644 specs/migrate-offline-to-messagepaginator/decisions.md create mode 100644 specs/migrate-offline-to-messagepaginator/plan.md create mode 100644 specs/migrate-offline-to-messagepaginator/spec.md create mode 100644 specs/migrate-offline-to-messagepaginator/state.json create mode 100644 specs/remove-legacy-channelstate-messages/decisions.md create mode 100644 specs/remove-legacy-channelstate-messages/plan.md create mode 100644 specs/remove-legacy-channelstate-messages/spec.md create mode 100644 specs/remove-legacy-channelstate-messages/state.json create mode 100644 specs/retained-items/spec.md create mode 100644 test/unit/pagination/utility.search.test.ts diff --git a/specs/migrate-offline-to-messagepaginator/decisions.md b/specs/migrate-offline-to-messagepaginator/decisions.md new file mode 100644 index 000000000..cee877ed7 --- /dev/null +++ b/specs/migrate-offline-to-messagepaginator/decisions.md @@ -0,0 +1,41 @@ +# Decisions — Offline migration + +## D-OFF-1 — Enhancement tranche (offline older-page pagination) + +**Status:** OPEN + +**Question:** Do we add the cursor-aware `AbstractOfflineDB.getChannelMessages` read + +`MessagePaginator.preloadFirstPageFromOfflineDb` (Task O7), enabling paginating older messages while +offline — or ship parity only (O1–O6)? + +**Trade-off:** O7 adds a new **abstract** method to the injection interface, which **breaks every +concrete offline DB implementation (RN/mobile) until they implement it** and must be released in +coordination with those SDKs. Parity (O1–O6) adds **no** abstract method and is safe for existing +implementers; it preserves today's behavior (offline shows the last-known latest page via channel +hydration; older-page loads need the network). + +**Recommendation:** **Ship parity (O1–O6) first**; schedule O7 as a follow-up with the RN/mobile +teams. Rationale: parity unblocks the parent plan's storage deletion without a cross-SDK breaking +change, and legacy offline didn't support older-page pagination either — so O7 is a net-new feature, +not a regression to avoid. + +## D-OFF-2 — `hydrateActiveChannels` paginator seed source (verify) + +**Status:** OPEN (verification) + +**Question:** Does the `hydrateActiveChannels` offline seed read the raw `ChannelAPIResponse.messages` +(the loop variable) or the just-populated `c.state.messages`? Mapping flagged this ambiguously. + +**Action:** Confirm by reading `client.ts:2324-2333` before Task O4/parent Task 10. If it reads +`c.state.messages`, O4 must repoint it to the response so removing legacy `_initializeState` message +population doesn't empty the offline seed. + +## D-OFF-3 — Persist paginated page loads (`upsertMessages`) now or later + +**Status:** OPEN + +**Question:** Include the `populateOfflineDbAfterQuery` → `upsertMessages` page-persist in O1, or defer? + +**Recommendation:** **Include in O1.** It closes a real gap (older pages fetched online are currently +not persisted for offline) using an existing abstract method (`upsertMessages`) — no interface break — +and makes O7 (offline read of those pages) actually useful later. diff --git a/specs/migrate-offline-to-messagepaginator/plan.md b/specs/migrate-offline-to-messagepaginator/plan.md new file mode 100644 index 000000000..8705d5d3a --- /dev/null +++ b/specs/migrate-offline-to-messagepaginator/plan.md @@ -0,0 +1,286 @@ +# Plan — Migrate offline-support off legacy `ChannelState` message storage + +See [`spec.md`](spec.md) for findings, the C1–C6 coupling table, and tranche scope; +[`decisions.md`](decisions.md) for the enhancement-tranche gate. + +## Worktree + +**Shares the parent plan's worktree/branch** — +`../stream-chat-js-worktrees/remove-legacy-channelstate-messages`, branch +`feat/remove-legacy-channelstate-messages`, base `feat/message-paginator-master-merge`. This +sub-plan is **not** a separate concurrent worktree: tasks O2/O5 (`channel.ts`), O4 (`client.ts`), and +O1 (`MessagePaginator.ts`) edit files the parent plan's chains also edit, so they must serialize with +those chains on the same branch. Offline-only-file tasks (O3, O7, O8 on `offline_support_api.ts` / +offline types / `MockOfflineDB`) may run in parallel worktrees if desired. + +## Task overview + +Parity tranche = O1–O6, O8 (+O9). Enhancement tranche = O7 (gated by decision D-OFF-1). The whole +sub-plan is a **required predecessor of the parent plan's Task 11 (delete storage)** — specifically +O1, O3, O5 must land before the parent removes `addMessageSorted`, and O6 depends on the parent's +Task 2 (`countUnread` on the paginator). Cross-plan dependencies are called out per task. + +--- + +## Task O1: `MessagePaginator` offline enablement + page persistence + +**File(s) to create/modify:** `src/pagination/paginators/MessagePaginator.ts` + +**Dependencies:** parent Task 1 (paginator capabilities — same file, serialize after it) + +**Status:** pending + +**Owner:** unassigned + +**Scope:** + +- Override `isOfflineSupportEnabled` → `!!this.channel.getClient().offlineDb` (mirror + `ChannelPaginator.ts:249`). Note this also changes `runQueryRetryable` to prefer stale data / suppress + errors when items exist — desired offline; confirm it doesn't mask errors online. +- Override `populateOfflineDbAfterQuery` to persist the fetched page via + `offlineDb.upsertMessages({ messages })` (needs `LocalMessage`→`MessageResponse` conversion), keyed + implicitly by each message's `cid`/`parent_id` (closes the "no `upsertMessages` on page load" gap). +- Do **not** add `preloadFirstPageFromOfflineDb` here (that is enhancement O7); first-page offline read + stays via channel hydration. + +**Acceptance Criteria:** + +- [ ] `isOfflineSupportEnabled` true iff a DB is injected; unit test with `MockOfflineDB`. +- [ ] A message-page load calls `upsertMessages` with the page's messages. +- [ ] `yarn types` + `yarn lint` clean. + +--- + +## Task O2: `channel.ts` — derive persistence `isLatestMessagesSet` from the paginator (C2) + +**File(s) to create/modify:** `src/channel.ts` + +**Dependencies:** parent Task 2 (channel.ts chain root — serialize within it) + +**Status:** pending + +**Owner:** unassigned + +**Scope:** + +- In `Channel.query()` (`channel.ts:1890-1897`), replace `messageSet.isLatest` (legacy) with a + paginator-derived "is latest page" signal (paginator at head / no `headward` cursor) when calling + `offlineDb.upsertChannels({ channels:[state], isLatestMessagesSet })`. + +**Acceptance Criteria:** + +- [ ] `upsertChannels` receives the correct `isLatestMessagesSet` for latest vs. older/jumped pages, + independent of `messageSets` — unit test. +- [ ] `yarn types` + `yarn lint` clean. + +--- + +## Task O3: Offline replay — send-message confirms via the paginator (C1) + +**File(s) to create/modify:** `src/offline-support/offline_support_api.ts` + +**Dependencies:** parent Task 1 (paginator ingest available); lands **before** parent Task 9/11 + +**Status:** pending + +**Owner:** unassigned + +**Scope:** + +- In `executeTask` send-message branch (`offline_support_api.ts:1296`), replace + `channel.state.addMessageSorted(newMessage, true)` with + `channel.messagePaginator.ingestItem(formatMessage(newMessage))` (thread branch already uses the + paginator via `upsertReplyLocally`). + +**Acceptance Criteria:** + +- [ ] Replaying a queued send-message surfaces the confirmed message in `messagePaginator`, not legacy + state — unit test with `MockOfflineDB`. +- [ ] `yarn types` + `yarn lint` clean. + +--- + +## Task O4: Cold-start hydration seeds the paginator from the response (C5) + +**File(s) to create/modify:** `src/client.ts` (coordinate with parent Task 10) + +**Dependencies:** parent Task 10 (client.ts chain — serialize; both edit `hydrateActiveChannels`) + +**Status:** pending + +**Owner:** unassigned + +**Scope:** + +- Verify/ensure `hydrateActiveChannels` (offlineMode path) seeds `messagePaginator.postQueryReconcile` + from the raw offline `ChannelAPIResponse.messages`, with **no** dependency on the legacy + `_initializeState`/`clearMessages` message population removed by parent Task 10. + +**Acceptance Criteria:** + +- [ ] Cold-start offline hydration populates `messagePaginator` for each restored channel with legacy + message population removed — unit test with `MockOfflineDB`. +- [ ] `yarn types` + `yarn lint` clean. + +--- + +## Task O5: Blocked/error-message DB cleanup off the paginator (C3) + +**File(s) to create/modify:** `src/channel.ts` (or a new small helper), consuming `messagePaginator` + +**Dependencies:** parent Task 2; lands **before** parent Task 11 (which deletes `filterErrorMessages`) + +**Status:** pending + +**Owner:** unassigned + +**Scope:** + +- Re-home the responsibility of `channel_state.filterErrorMessages()` (`channel_state.ts:988-998`): + find blocked/error messages via the paginator and call `offlineDb.hardDeleteMessage({ id })`, then + drop them from the paginator. Ensure the trigger points that called `filterErrorMessages` now call + the paginator-based cleanup. + +**Acceptance Criteria:** + +- [ ] Blocked/error messages are hard-deleted from the DB and removed from the paginator; no reliance + on `latestMessages` — unit test. +- [ ] `yarn types` + `yarn lint` clean. + +--- + +## Task O6: Read-state persistence uses paginator-backed `countUnread` (C6) + +**File(s) to create/modify:** none of its own — verification task over `offline_support_api.ts` reads + +**Dependencies:** parent Task 2 (`countUnread`/`countUnreadMentions` migrated to the paginator) + +**Status:** pending + +**Owner:** unassigned + +**Scope:** + +- Confirm `handleNewMessage` (`:630/:634`) and `handleChannelTruncatedEvent` (`:891/:895`) still + compute correct `unread_messages` once `countUnread` reads the paginator; `channel.state.read` + stays. Add regression coverage. (No code change expected beyond the parent Task 2 migration; this + task exists to gate/verify the dependency.) + +**Acceptance Criteria:** + +- [ ] Offline read-count persistence unaffected by the storage removal — regression test. + +--- + +## Task O7 (GATED): Cursor-aware offline message read → true offline pagination + +**File(s) to create/modify:** `src/offline-support/types.ts` (+ `offline_support_api.ts` abstract +method), `src/pagination/paginators/MessagePaginator.ts` + +**Dependencies:** O1; **decision D-OFF-1** (coordinated breaking interface change) + +**Status:** pending (blocked on decision) + +**Owner:** unassigned + +**Scope:** + +- Add abstract `getChannelMessages({ cid, parent_id?, id_lt?, id_gt?, id_around?, limit }): +MessageResponse[] | null` to `OfflineDBApi`. +- Add `MessagePaginator.preloadFirstPageFromOfflineDb` (mirror `ChannelPaginator`), reading via the new + method keyed by `buildFilters()` (`cid`(+`parent_id`)) and the query cursor; hydrate through + `postQueryReconcile`. +- Coordinate the interface change with RN/mobile SDKs (see O9). + +**Acceptance Criteria:** + +- [ ] Older-page loads resolve from the DB while offline, via `MockOfflineDB` implementing the new + method — unit test. +- [ ] Interface change documented as breaking for offline implementers. + +--- + +## Task O8: Offline tests + +**File(s) to create/modify:** `test/unit/offline-support/offline_support_api.test.ts`, `MockOfflineDB` (in `test/unit/offline-support/`) + +**Dependencies:** O1–O6 (and O7 if in scope) + +**Status:** pending + +**Owner:** unassigned + +**Scope:** + +- Update `MockOfflineDB` for any new behavior; rewrite offline tests that assert against + `state.messages`/`addMessageSorted` to assert against `messagePaginator`; add the replay / + hydration / persistence / cleanup coverage from O1–O6. + +**Acceptance Criteria:** + +- [ ] `yarn test-unit test/unit/offline-support` green; no legacy-storage references. + +--- + +## Task O9: Coordination & docs for the interface change (only if O7 in scope) + +**File(s) to create/modify:** `CLAUDE.md`, `developers/*`, release notes/migration guide + +**Dependencies:** O7 + +**Status:** pending + +**Owner:** unassigned + +**Scope:** + +- Document the new `AbstractOfflineDB.getChannelMessages` requirement and the `BREAKING CHANGE` for + offline implementers; open coordination tickets with RN/mobile SDK teams. + +**Acceptance Criteria:** + +- [ ] Migration note published; downstream tickets linked. + +--- + +## Execution Order + +``` +(interleaves with parent plan — same branch) + +After parent Task 1: +└── O1: MessagePaginator offline enablement + page persist + +After parent Task 2: +├── O2: channel.ts isLatestMessagesSet from paginator +├── O5: blocked/error DB cleanup off paginator +└── O6: read-count persistence verification (gated on parent Task 2) + +After parent Task 1 (offline-only file, parallel): +└── O3: replay via paginator ingest ── MUST precede parent Task 9/11 + +After parent Task 10: +└── O4: cold-start hydration seed from response + +Gated (decision D-OFF-1), after O1: +└── O7: cursor-aware offline read → O9 coordination/docs + +After O1–O6 (+O7): +└── O8: offline tests + +GATE: O1, O3, O5 complete ⇒ parent plan Task 11 (delete storage) may proceed. +``` + +## File Ownership Summary + +| Task | Creates/Modifies | Shared-file note | +| ---- | ------------------------------------------------------------------------------- | --------------------------------------------- | +| O1 | `src/pagination/paginators/MessagePaginator.ts` | after parent Task 1 | +| O2 | `src/channel.ts` | within parent channel.ts chain (after Task 2) | +| O3 | `src/offline-support/offline_support_api.ts` | offline-only | +| O4 | `src/client.ts` | within parent client.ts chain (with Task 10) | +| O5 | `src/channel.ts` (+ optional helper) | within parent channel.ts chain | +| O6 | (verification only) | depends on parent Task 2 | +| O7 | `src/offline-support/types.ts`, `offline_support_api.ts`, `MessagePaginator.ts` | gated; offline-only + paginator | +| O8 | `test/unit/offline-support/*` | offline-only | +| O9 | `CLAUDE.md`, `developers/*` | docs | diff --git a/specs/migrate-offline-to-messagepaginator/spec.md b/specs/migrate-offline-to-messagepaginator/spec.md new file mode 100644 index 000000000..acd344561 --- /dev/null +++ b/specs/migrate-offline-to-messagepaginator/spec.md @@ -0,0 +1,72 @@ +# Spec — Migrate offline-support off legacy `ChannelState` message storage + +Status: **planned**. Repo: `stream-chat` (JS SDK). **Sub-initiative of** +[`../remove-legacy-channelstate-messages`](../remove-legacy-channelstate-messages/spec.md) — this is +its Task 15, expanded. Resolves decision **D1** in that plan. + +## Goal + +Make offline-support work with `channel.messagePaginator` as the message source of truth, so the +legacy `ChannelState` message store (`messageSets`/`addMessageSorted`/`state.messages`/…) can be +deleted without breaking offline read/replay/persist. Offline-support has **no default implementation +in this package** (`AbstractOfflineDB` is injected by RN/mobile SDKs), so any change to the abstract +interface is a **coordinated, breaking change** for those SDKs. + +## Key findings (from subsystem + paginator mapping) + +1. **Offline message READS already reach the paginator without legacy state.** Cold start goes + `offlineDb.getChannelsForQuery()` → `client.hydrateActiveChannels(rows, {offlineMode:true})` → + `postQueryReconcile` seeds `messagePaginator` from the **raw `ChannelAPIResponse.messages`** + (`client.ts:2324-2333`). So the first/latest page works response-driven. _(Verify the + `hydrateActiveChannels` seed reads the response var, not `c.state.messages` — one mapping pass + flagged it ambiguously; confirm before relying on it.)_ +2. **Offline message WRITES already persist from the response, not `ChannelState`.** + `client.queryChannels` → `offlineDb.upsertChannels({channels, isLatestMessagesSet:true})` + (`client.ts:2199`); `Channel.query()` → `upsertChannels` (`channel.ts:1890`). **But** the + `isLatestMessagesSet` flag on the single-channel path is derived from legacy `messageSet.isLatest` + (`channel.ts:1890-1897`) — a coupling to sever. +3. **There is NO cursor-aware per-message read in `OfflineDBApi`** — only channel-bundled + `getChannels`/`getChannelsForQuery`. So offline _older-page pagination_ is not supported today + (legacy didn't support it either) and is an **enhancement**, not parity. +4. **`upsertMessages` is not called on paginated page loads** — only for WS `message.new` and inside + `upsertChannels`. Persisting older offline-fetched pages would be new. + +## Legacy couplings to sever (parity) + +| # | Site | Today | Target | +| --- | ---------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | +| C1 | `offline_support_api.ts:1296` | send-message replay → `channel.state.addMessageSorted(newMessage,true)` | `channel.messagePaginator.ingestItem(formatMessage(newMessage))` | +| C2 | `channel.ts:1890-1897` | `upsertChannels` `isLatestMessagesSet` from `messageSet.isLatest` | derive "is latest page" from the paginator (head / no headward cursor) | +| C3 | `channel_state.ts:988-998` | `filterErrorMessages()` scans `latestMessages`, drives `offlineDb.hardDeleteMessage` | paginator-driven blocked/error-message cleanup (this method is removed by the parent plan) | +| C4 | `channel.ts:729` | offline `deleteReaction` → `state.messages.find` | paginator `getItem` _(already covered by parent plan Task 2)_ | +| C5 | `client.ts:2288-2333`, `channel.ts:1836` | cold-start hydration populates legacy `messageSets` then seeds paginator from it | seed paginator directly from the offline `ChannelAPIResponse` _(coordinate with parent Task 10)_ | +| C6 | `offline_support_api.ts:630,634,891,895` | offline read persistence uses `channel.state.read` (stays) + `countUnread()` (legacy) | `state.read` stays; `countUnread` becomes paginator-backed _(parent plan Task 2 dependency)_ | + +## Scope + +**Parity tranche (default, no interface break):** sever C1–C6 so offline works after the storage +deletion, using the reads/writes that already flow through channel hydration + `upsertChannels`. +Optionally persist paginated page loads via `upsertMessages` (`populateOfflineDbAfterQuery`). + +**Enhancement tranche (gated — coordinated breaking change):** add a cursor-aware message read to +`AbstractOfflineDB` (e.g. `getChannelMessages({cid, parent_id?, id_lt/id_gt/limit})`) + a +`LocalMessage`↔`MessageResponse` conversion + `MessagePaginator.preloadFirstPageFromOfflineDb`, giving +true offline older-page pagination. Requires RN/mobile SDK implementations to add the method. + +## Blast radius + +- **Injection interface (`AbstractOfflineDB` / `OfflineDBApi`):** parity tranche adds no abstract + method (safe for existing RN/mobile impls). Enhancement tranche adds one abstract method → **breaks + every concrete impl until they implement it**; needs coordinated release with RN/mobile. +- **Shared files** with the parent plan: `channel.ts`, `client.ts`, `MessagePaginator.ts` — this + sub-plan interleaves with the parent's same-file chains (see plan.md), it is **not** a concurrent + worktree. +- **Tests:** `test/unit/offline-support/offline_support_api.test.ts` + `MockOfflineDB`. + +## Acceptance + +- After the parent plan deletes `ChannelState` message storage, offline replay, persistence, + cold-start hydration, reaction-delete, and read-count persistence all work off `messagePaginator` + (parity tranche), verified with a `MockOfflineDB` unit suite. +- No offline code references `channel.state.messages`/`latestMessages`/`addMessageSorted`/ + `findMessage`/`messageSet.isLatest`. diff --git a/specs/migrate-offline-to-messagepaginator/state.json b/specs/migrate-offline-to-messagepaginator/state.json new file mode 100644 index 000000000..785a37ad9 --- /dev/null +++ b/specs/migrate-offline-to-messagepaginator/state.json @@ -0,0 +1,82 @@ +{ + "feature": "migrate-offline-to-messagepaginator", + "status": "planned", + "parent": "remove-legacy-channelstate-messages", + "worktree": { + "path": "../stream-chat-js-worktrees/remove-legacy-channelstate-messages", + "branch": "feat/remove-legacy-channelstate-messages", + "base": "feat/message-paginator-master-merge", + "note": "Shares the parent worktree/branch; not a concurrent worktree (shared files channel.ts/client.ts/MessagePaginator.ts)." + }, + "openDecisions": ["D-OFF-1", "D-OFF-2", "D-OFF-3"], + "gate": "O1, O3, O5 must complete before parent plan Task 11 (delete ChannelState storage).", + "tasks": [ + { + "id": "O1", + "name": "MessagePaginator offline enablement + page persist", + "status": "pending", + "owner": null, + "dependencies": ["parent:1"] + }, + { + "id": "O2", + "name": "channel.ts isLatestMessagesSet from paginator", + "status": "pending", + "owner": null, + "dependencies": ["parent:2"] + }, + { + "id": "O3", + "name": "Replay send-message via paginator ingest", + "status": "pending", + "owner": null, + "dependencies": ["parent:1"], + "note": "Must precede parent Task 9/11." + }, + { + "id": "O4", + "name": "Cold-start hydration seed from response", + "status": "pending", + "owner": null, + "dependencies": ["parent:10"] + }, + { + "id": "O5", + "name": "Blocked/error DB cleanup off paginator", + "status": "pending", + "owner": null, + "dependencies": ["parent:2"], + "note": "Must precede parent Task 11." + }, + { + "id": "O6", + "name": "Read-count persistence verification", + "status": "pending", + "owner": null, + "dependencies": ["parent:2"] + }, + { + "id": "O7", + "name": "Cursor-aware offline message read (enhancement)", + "status": "blocked", + "owner": null, + "dependencies": ["O1"], + "blockedBy": "D-OFF-1" + }, + { + "id": "O8", + "name": "Offline tests", + "status": "pending", + "owner": null, + "dependencies": ["O1", "O2", "O3", "O4", "O5", "O6"] + }, + { + "id": "O9", + "name": "Interface-change coordination & docs", + "status": "pending", + "owner": null, + "dependencies": ["O7"], + "blockedBy": "D-OFF-1" + } + ] +} diff --git a/specs/remove-legacy-channelstate-messages/decisions.md b/specs/remove-legacy-channelstate-messages/decisions.md new file mode 100644 index 000000000..98c557d32 --- /dev/null +++ b/specs/remove-legacy-channelstate-messages/decisions.md @@ -0,0 +1,62 @@ +# Decisions — Remove legacy `ChannelState` message storage + +Open decisions that gate scope/sequencing. Update `Status` and record the resolution inline. + +## D1 — Offline-support scope (gates Task 11 / Task 15) + +**Status:** OPEN + +**Question:** Does this initiative migrate offline-support (Task 15), or defer it? + +**Constraint:** Deleting the store (Task 11) removes `ChannelState.addMessageSorted`, which the offline +replay path calls (`offline_support_api.ts:1296`). So there is **no "delete now, migrate offline +later"** option that also does a clean removal. Either: + +- (a) **Include Task 15** as a required predecessor of Task 11, or +- (b) **Descope offline** but then Task 11 must keep a compatibility write path for offline replay + (contradicts the hard-removal goal and D2), or +- (c) **Two-initiative split:** land Phases 1-4 (readers migrated, dual-write still on) now; do the + actual deletion (Tasks 9-14) + offline (Task 15) in a follow-up once offline is ready. + +**Recommendation:** (a) if offline can be scheduled now; otherwise (c) — ship the reader migration and +keep dual-write until offline is ready, so the codebase is never in a broken half-migrated state. +Note: offline has no default impl in this package (injected by RN/mobile SDKs), so its migration also +needs coordination with those SDKs. + +**Update:** offline is now planned in detail in +[`../migrate-offline-to-messagepaginator`](../migrate-offline-to-messagepaginator/plan.md). Key result: +the **parity tranche needs no breaking interface change** (offline reads/writes already flow through +channel hydration + `upsertChannels`), so option (a) is cheaper than feared — only the parity tranche +(sub-plan O1, O3, O5) must land before Task 11. The breaking piece (cursor-aware offline read for +older-page pagination) is isolated to the sub-plan's gated enhancement tranche, so it need not block +this removal. This makes **(a) with parity-only offline** the recommended path. + +## D2 — Deprecation shim vs. hard removal (public API) + +**Status:** OPEN + +**Question:** Keep `get messages()` / `get latestMessages()` as `@deprecated` delegates that read the +paginator for one major, or remove outright? + +**Recommendation:** **Hard removal.** This is already a breaking major on a WIP branch; a shim +re-introduces the second read path we are deleting and invites drift. (Reconsider only if external +Angular/RN consumers need a migration window.) + +## D3 — `pending_messages` + +**Status:** OPEN + +**Question:** Keep `ChannelState.pending_messages` (server pending list) or migrate/remove it? + +**Recommendation:** **Keep.** It is message-adjacent but not part of `messageSets`, has its own +lifecycle, and is out of scope for this removal. + +## D4 — Threads in first cut + +**Status:** OPEN + +**Question:** Include thread migration (Tasks 6, 7) now, or defer with the main-list-only cut? + +**Recommendation:** **Include.** `ChannelState.threads` has no external `src` readers and `Thread` +already owns a `messagePaginator`, so the thread migration is well-contained and blocks a clean +`ChannelState` deletion anyway. diff --git a/specs/remove-legacy-channelstate-messages/plan.md b/specs/remove-legacy-channelstate-messages/plan.md new file mode 100644 index 000000000..9d4e463b9 --- /dev/null +++ b/specs/remove-legacy-channelstate-messages/plan.md @@ -0,0 +1,572 @@ +# Plan — Remove legacy `ChannelState` message storage + +See [`spec.md`](spec.md) for goal, gaps, and blast radius; [`decisions.md`](decisions.md) for open +scope decisions (offline handling in particular gates the final tasks). + +## Worktree + +**Worktree path (JS SDK — primary):** `../stream-chat-js-worktrees/remove-legacy-channelstate-messages` +**Branch:** `feat/remove-legacy-channelstate-messages` +**Base branch:** `feat/message-paginator-master-merge` +**Preview branch:** `agent/feat/remove-legacy-channelstate-messages` + +React-side tasks (Task 8) run in a **separate** React worktree: +`../stream-chat-react-worktrees/remove-legacy-channelstate-messages`, branch +`feat/remove-legacy-channelstate-messages`, base `feat/message-paginator-master-merge`. Its +`node_modules/stream-chat` should point at the JS worktree above. + +All work MUST happen in these worktrees, not the main checkouts. Create/sync via the worktrees skill. + +## Task overview + +Tasks are self-contained and run in dedicated worktrees. **`src/channel.ts` and `src/client.ts` are +serialization chokepoints** — each is edited across several phases, so their tasks form dependency +chains (only one agent touches a file at a time). Reader migrations that live in their own files run +in parallel. The critical path is: paginator capabilities → migrate every legacy reader → stop +dual-writing → delete the store → types/exports → tests. + +--- + +## Task 1: Paginator capability parity + +**File(s) to create/modify:** `src/pagination/paginators/MessagePaginator.ts`, `src/pagination/paginators/BasePaginator.ts` + +**Dependencies:** None + +**Status:** done + +**Owner:** unassigned + +**Scope:** + +- Add a head-anchored **latest-window accessor** (e.g. `latestItems` / `lastItem`) that returns the + newest loaded messages regardless of the active interval (gap 2) — the foundation for `lastMessage` + and unread counting off the paginator. +- Add **partial truncation** (`truncated_at` cutoff clear) alongside the existing full + `clearStateAndCache()` (gap 3). +- Confirm/adjust `isHead`↔`isUpToDate` parity for `message.new` routing of out-of-range messages + (gap 7); document the mapping. + +**Acceptance Criteria:** + +- [ ] `latestItems`/`lastItem` returns head-window messages after a jump-to-message (unit test). +- [ ] Partial-truncate drops only messages older than `truncated_at`, keeps newer (unit test). +- [ ] `yarn types` + `yarn lint` clean. + +--- + +## Task 2: `channel.ts` — seed paginator on all query paths + migrate derived readers + +**File(s) to create/modify:** `src/channel.ts` + +**Dependencies:** Task 1 + +**Status:** done + +**Owner:** unassigned + +**Scope:** + +- Seed/update `messagePaginator` from `channel.query()` (direct), `channel.search()`, and + `loadMessageIntoState` so the paginator always reflects fetched pages (gap 4). (`watch()` and + `hydrateActiveChannels` already seed.) +- Re-point onto the paginator: `lastMessage()` (L1389), `countUnread()`/`countUnreadMentions()` + (L1706/L1728) using Task 1's latest-window accessor, `_extendEventWithOwnReactions` `findMessage`→ + `messagePaginator.getItem` (L2895), and the `deleteReaction` offline read (L729). +- **Leave the legacy dual-writes in place** (removed later in Task 9) so this stays a safe, parity-only + change. + +**Acceptance Criteria:** + +- [ ] `lastMessage`, `countUnread`, `countUnreadMentions` return identical results reading the + paginator vs. the (still-present) legacy store — parity unit tests. +- [ ] A `query()`/`search()`/`loadMessageIntoState` call leaves `messagePaginator` populated. +- [ ] `yarn types` + `yarn lint` clean; `yarn test-unit` green vs. baseline. + +--- + +## Task 3: `CooldownTimer` reader migration + +**File(s) to create/modify:** `src/CooldownTimer.ts` + +**Dependencies:** Task 1 + +**Status:** done + +**Owner:** unassigned + +**Scope:** + +- `refresh()` (L105) reads `channel.state.latestMessages` for the own latest-message date — switch to + the paginator's latest-window accessor. + +**Acceptance Criteria:** + +- [ ] Cooldown refresh derives the same own-latest-message date from the paginator (unit test). +- [ ] `yarn types` + `yarn lint` clean. + +--- + +## Task 4: `messageDelivery` reader migration (both branches) + +**File(s) to create/modify:** `src/messageDelivery/MessageDeliveryReporter.ts`, `src/messageDelivery/MessageReceiptsTracker.ts` + +**Dependencies:** Task 1, Task 6 + +**Scope:** + +- `MessageDeliveryReporter` channel branch (L137, `latestMessages`) → paginator latest-window; thread + branch (L143, `Thread.state...replies`) → `thread.messagePaginator` (needs Task 6). +- `MessageReceiptsTracker.findMessageByTimestamp` (L186) → paginator-based lookup. + +**Status:** done + +**Owner:** unassigned + +**Acceptance Criteria:** + +- [ ] Delivery-candidate selection and receipt mapping unchanged vs. legacy (unit tests). +- [ ] `yarn types` + `yarn lint` clean. + +--- + +## Task 5: `client.ts` — `_updateUserReferences` reader migration + +**File(s) to create/modify:** `src/client.ts` + +**Dependencies:** Task 1 + +**Status:** done + +**Owner:** unassigned + +**Scope:** + +- `_updateUserReferences` (L1487) currently calls `state.updateUserMessages` across all active + channels. Provide a paginator-based equivalent that patches cached messages' user data (via + `messagePaginator` item index). Leave the legacy call until Task 10 (chokepoint chain on `client.ts`). + +**Acceptance Criteria:** + +- [ ] Updating a user propagates to cached paginator messages across active channels (unit test). +- [ ] `yarn types` + `yarn lint` clean. + +--- + +## Task 6: `thread.ts` — make the paginator the reply source of truth + +**File(s) to create/modify:** `src/thread.ts` + +**Dependencies:** Task 1 + +**Status:** out-of-scope (threads excluded from this initiative) + +**Owner:** unassigned + +**Scope:** + +- Migrate `Thread.state.replies` consumers to `thread.messagePaginator.items`; retire + `upsertReplyLocally`/`deleteReplyLocally`/`failedRepliesMap` double-bookkeeping (thread.ts L494-705, + L776 NOTE), keeping WS reply sync on the paginator. +- Expose whatever accessor `MessageDeliveryReporter` (Task 4) needs for the thread branch. + +**Acceptance Criteria:** + +- [ ] Thread reply list, optimistic send, and failed-reply retry work off the paginator (unit tests). +- [ ] `yarn types` + `yarn lint` clean; thread tests green vs. baseline. + +--- + +## Task 7: `channel.ts` — decouple `getReplies` from `ChannelState.threads` + +**File(s) to create/modify:** `src/channel.ts` + +**Dependencies:** Task 2, Task 6 + +**Status:** out-of-scope (threads excluded from this initiative) + +**Owner:** unassigned + +**Scope:** + +- `Channel.getReplies` (L1600) writes `state.addMessagesSorted` (populating `ChannelState.threads` + + sets). Route replies to `thread.messagePaginator` (Task 6) instead; stop writing `ChannelState`. +- Same-file chain after Task 2. + +**Acceptance Criteria:** + +- [ ] `getReplies` populates the thread paginator, not `ChannelState.threads` (unit test). +- [ ] `yarn types` + `yarn lint` clean. + +--- + +## Task 8: React SDK — migrate readers to `messagePaginator` + +**File(s) to create/modify (React repo):** `src/context/MessageBounceContext.tsx`, `src/components/ChannelListItem/{utils.tsx,ChannelListItem.tsx,ChannelListItemUI.tsx}`, `src/components/MessageList/hooks/useMarkRead.ts` + +**Dependencies:** None (reads `channel.messagePaginator`, already populated for listed channels) + +**Status:** done + +**Owner:** unassigned + +**Scope:** + +- Replace `channel.state.removeMessage` (MessageBounceContext) with the paginator remove. +- Replace last-message/preview/title reads (`ChannelListItem` family) and `useMarkRead`'s + `latestMessages.slice(-1)` with `channel.messagePaginator` accessors. +- Update `stream-chat-react/CLAUDE.md` "DO NOT" guidance that references `addMessageSorted`/ + `state.messages` (also flag the JS `CLAUDE.md`; JS docs handled in Task 14). + +**Acceptance Criteria:** + +- [ ] No `state.messages`/`latestMessages`/`addMessageSorted`/`removeMessage` in `stream-chat-react/src`. +- [ ] React `yarn types` + `yarn lint-fix` clean; example builds; headless vite check renders channel + previews and mark-read correctly. + +--- + +## Task 9: `channel.ts` — stop dual-writing legacy in event handlers + `_initializeState` + +**File(s) to create/modify:** `src/channel.ts` + +**Dependencies:** Task 7, Task 3, Task 4, Task 5, Task 6, Task 8 (all readers migrated), Task 15 if offline in scope + +**Status:** done + +**Owner:** unassigned + +**Scope:** + +- Remove legacy twins from WS handlers (`message.new/updated/deleted`, reactions, `user.messages.deleted`, + `channel.truncated` → use Task 1 partial-truncate, `channel.hidden`) and from `_initializeState`, + keeping only the `messagePaginator` calls. Re-home non-message side effects (`last_message_at` + update, `pinnedMessages` handling stays via its own methods). + +**Acceptance Criteria:** + +- [ ] All message WS events reflected via the paginator only; pinned/read/members/typing unaffected. +- [ ] `yarn test-unit` green vs. baseline (channel event tests updated in Task 13). + +--- + +## Task 10: `client.ts` — stop dual-writing in `hydrateActiveChannels` + +**File(s) to create/modify:** `src/client.ts` + +**Dependencies:** Task 5, Task 9 + +**Status:** done + +**Owner:** unassigned + +**Scope:** + +- Drop `_initializeState`/`clearMessages`/`messageSetPagination` message seeding in + `hydrateActiveChannels` (L2292-2319), keeping only `postQueryReconcile` (paginator). Keep poll/reminder + hydration sourced from the raw API response rather than `channelState.messages`. + +**Acceptance Criteria:** + +- [ ] `queryChannels` seeds only the paginator; polls/reminders still hydrate. +- [ ] `yarn types` + `yarn lint` clean; `yarn test-unit` green vs. baseline. + +--- + +## Task 11: Delete the storage from `ChannelState` + `utils.messageSetPagination` + +**File(s) to create/modify:** `src/channel_state.ts`, `src/channel.ts`, `src/client.ts`, `src/thread.ts`, +`src/utils.ts`, `src/pagination/paginators/MessagePaginator.ts`, `src/offline-support/offline_support_api.ts` + +**Dependencies:** Task 9, Task 10 (and Task 15 if offline in scope) + +**Status:** in progress (sub-steps 1-2 done; step sequence below in progress) + +**Owner:** claude + +**Scope:** + +- Remove `messageSets`, `messages`/`latestMessages` accessors, all pure message-set methods + private + geometry helpers, and the `threads` record (see `spec.md` scope list). Split `clearMessages` so the + `pinnedMessages` reset survives (e.g. `clearPinned`). Remove `messageSetPagination` from `utils.ts`. +- Resolve `pending_messages` per `decisions.md` #3. + +**`channel.state.threads` REMOVED (confirmed with owner):** the `Thread` object already owns reply +state (`Thread.state.replies` + `Thread.messagePaginator`, with its own message/reaction subscriptions), +so `channel.state.threads` is a redundant legacy shadow. Removing it lets the shared methods be DELETED +outright rather than kept for threads. Formerly-out-of-scope Tasks 6/7 fold in here. + +**Key coupling — `own_reactions`:** today `ChannelState.addReaction`/`removeReaction` stop a cross-user +reaction WS event from wiping the current user's `own_reactions` by reading the local cache +(`state.messages` / `state.threads`). With both caches gone this preservation is re-homed onto the +paginators (owner-approved). + +**Execution sequence (verified sub-steps):** + +1. **[DONE]** Add `MessagePaginator.reflectReaction({ message, reaction, removed, enforceUnique })` — read the + cached item via `getItem`, preserve the current user's `own_reactions`, apply the incoming reaction, ingest. + Reused by both the channel- and thread-level reaction handlers. Added 4 unit tests (cross-user preserve, + own-reaction add, delete-removal, other-user-not-added). Purely additive — JS 3538 pass/0 fail, types+lint clean. +2. **[DONE]** Rewire the **channel** reaction handlers (`reaction.new/deleted/updated`): the paginator path is + now `channel.messagePaginator.reflectReaction` (was `ingestItem(enriched event.message)` — behavior-equivalent). + `channelState.addReaction/removeReaction` are kept for now purely for their `pinnedMessages` side-effect + (dropped in step 3). **Thread reply reactions are NOT rewired here:** the thread UI reads + `Thread.state.replies` (not `Thread.messagePaginator`), and today reply `own_reactions` are preserved because + the channel's `addReaction` enriches the shared `event.message` before the Thread's handler runs. That + preservation must move onto `Thread.state.replies` — done in step 4/5 together with removing + `channel.state.threads` (when the channel stops enriching reply events). A paginator-level + `reflectReaction` on the Thread would not fix the `state.replies`-based UI. +3. Delete the message-list/thread methods: `addMessagesSorted`/`addMessageSorted`, `removeMessage`, + `deleteUserMessages`, `findMessage`, `addReaction`/`removeReaction`; shrink `_updateMessage` / + `_updateQuotedMessageReferences` to pinned-only. +4. Remove writes/callers: `message.new/updated/deleted/channel.truncated/channel.hidden` handlers, + `getReplies` (channel.ts) thread population, `_initializeState` split (keep read/members/watchers/pinned), + `client._deleteUserMessageReference`, `offline_support_api`. Re-home `last_message_at`. +5. Delete storage: `messageSets` / `messages` / `latestMessages` / `threads` + geometry helpers + + `utils.messageSetPagination`. +6. Test surgery: remove/rewrite the message-set machinery + thread `ChannelState` tests (both repos); + keep pinned/reaction-merge coverage; add `reflectReaction` tests. +7. Verify JS + React suites green; `yarn types` + `yarn lint` clean in both repos. + +**Acceptance Criteria:** + +- [ ] `ChannelState` retains only non-message stores (read, members, watchers, typing, pinned, pending); file compiles. +- [ ] No `messageSets`/`messages`/`latestMessages`/`threads`/`addMessageSorted`/etc. remain in `stream-chat/src`. +- [ ] Cross-user reactions preserve the current user's `own_reactions` (channel + thread), verified by tests. +- [ ] Both suites green vs. baseline; types + lint clean. + +--- + +## Task 12: Types & public exports + +**File(s) to create/modify:** `src/types.ts`, `src/index.ts`, `src/channel_state.ts` (type anchor) + +**Dependencies:** Task 11 + +**Status:** pending + +**Owner:** unassigned + +**Scope:** + +- Remove `MessageSet`, `MessageSetType`, `isLatestMessageSet`. Resolve the + `ReturnType` type anchor — retype `pinnedMessages` and remaining + signatures against `LocalMessage` (keep `formatMessage` as a util). +- Land the removal commit with a `BREAKING CHANGE:` footer (no manual version bump). + +**Acceptance Criteria:** + +- [ ] `yarn types` clean across `src`; public export surface updated; `BREAKING CHANGE` documented. + +--- + +## Task 13: Tests — rewrite/remove legacy suites + add parity suites + +**File(s) to create/modify:** `test/unit/channel_state.test.js`, `test/unit/channel.test.js`, `test/unit/client.test.js`, `test/unit/CooldownTimer.test.ts`, `test/unit/offline-support/offline_support_api.test.ts`, `test/unit/poll_manager.test.ts`, `test/typescript/response-generators/message.js` + +**Dependencies:** Task 11, Task 12 + +**Status:** pending + +**Owner:** unassigned + +**Scope:** + +- Rewrite/remove `channel_state.test.js` (message-set suites gone; keep non-message coverage). Update + `channel.test.js`/`client.test.js`/others to assert against the paginator. Fold the parity tests + authored in Tasks 1-7 into permanent coverage. + +**Acceptance Criteria:** + +- [ ] `yarn test-unit` green; no test references `messageSets`/`addMessageSorted`/`state.messages`. + +--- + +## Task 14: Docs + +**File(s) to create/modify:** `CLAUDE.md` (JS), `stream-chat-react/CLAUDE.md`, `developers/*` as needed + +**Dependencies:** Task 12 + +**Status:** pending + +**Owner:** unassigned + +**Scope:** + +- Remove/replace stale "use `channel.state.addMessageSorted()`/`removeMessage()`/`state.messages`" + guidance; document `messagePaginator` as the source of truth. + +**Acceptance Criteria:** + +- [ ] No stale legacy-storage guidance in either `CLAUDE.md`. + +--- + +## Task 15 (GATED): Offline-support migration → see sub-plan + +**Expanded into its own spec:** +[`../migrate-offline-to-messagepaginator`](../migrate-offline-to-messagepaginator/plan.md). + +**Dependencies:** Task 1 — **decision-gated** (see `decisions.md` D1). If the storage is deleted +(Task 11), the sub-plan's **parity tranche (O1, O3, O5) is a required predecessor of Task 11**, because +Task 11 removes the `addMessageSorted` the offline replay path calls. If descoped, Task 11 must instead +retain a compatibility path (conflicts with hard removal). + +**Status:** planned (see sub-plan; enhancement tranche blocked on decision D-OFF-1) + +**Owner:** unassigned + +**Summary (full detail in the sub-plan):** offline message _reads_ and _writes_ already flow through +channel hydration + `upsertChannels` (response-driven), so **parity needs no interface change** — only +severing narrow legacy couplings (send-message replay, `isLatestMessagesSet` flag, `filterErrorMessages` +DB cleanup, reaction-delete read, cold-start seed). A cursor-aware offline read for older-page +pagination is a **gated, breaking** enhancement requiring RN/mobile coordination. + +**Acceptance Criteria:** + +- [ ] Sub-plan parity tranche (O1–O6, O8) complete; offline tests green with a `MockOfflineDB`. + +--- + +## Task 16 (OPTIMIZATION, non-blocking): author index for O(1) per-user updates + +**File(s) to create/modify:** `src/pagination/ItemIndex.ts` (or `src/pagination/paginators/MessagePaginator.ts`) + +**Dependencies:** Task 5. **Not** gating Task 11 — a performance follow-up that can land any time after Task 5. + +**Status:** pending + +**Owner:** unassigned + +**Scope:** + +- `MessagePaginator.reflectUserUpdate(user)` (Task 5) — and later per-user operations such as + `applyMessageDeletionForUser` and the `deleteUserMessages` migration — currently scan + `_itemIndex.values()` (every cached message) to find a single user's messages: O(all cached). +- Maintain an **author secondary index** `userId → Set`, updated on ingest/remove, so + these become O(that user's cached messages). Options: (a) a message-aware secondary index owned by + `MessagePaginator`, or (b) generalize `ItemIndex` to accept optional secondary key extractors + (e.g. `getSecondaryKeys`). Prefer whichever keeps `ItemIndex` generic and the messages concern in + `MessagePaginator`. +- Re-point `reflectUserUpdate` (and any other per-user scans) at the author index. + +**Acceptance Criteria:** + +- [ ] `reflectUserUpdate` no longer iterates all cached messages; unit test asserts only the target + user's messages are visited (e.g. via a spy/counter) and behavior is unchanged. +- [ ] `yarn types` + `yarn lint` clean; full suite green. + +--- + +## Task 18 (FOLLOW-ON): `channel.state.pinnedMessages` → `channel.pinnedMessagesPaginator` + +**File(s):** `src/pagination/paginators/PinnedMessagePaginator.ts` (new), `src/channel.ts`, `src/channel_state.ts`, +`src/client.ts`, stream-chat-react pinned views. **Dependencies:** Task 11. **Status:** planned. **Owner:** unassigned + +**Why a dedicated paginator, not a bare `MessagePaginator` + filters:** pinned messages use a _different +endpoint_ (`channel.getPinnedMessages` → `/pinned_messages`) with `PinnedMessagePaginationOptions` (id-based +cursors) and `PinnedMessagesSort`, and sort by `pinned_at` — whereas `MessagePaginator.buildFilters()`, its +`created_at` sort, and its `query()` (→ `channel.query({messages})`) are baked for the main list. You can't pass +those in to a vanilla instance; they're methods to override. + +**Recommended design:** `class PinnedMessagePaginator extends MessagePaginator`, overriding: + +- `buildFilters()` → `{ cid, pinned: true }` and `shouldIncludeMessageInInterval()` → `!shadowed && !!pinned`. +- item comparator → `pinned_at` (desc), `query()`/`doRequest` → `channel.getPinnedMessages(options, sort)`, + `getNextQueryShape` + cursor derivation → id-based. +- Reuses the interval store + `ingestItem` + `reflect*` (`reflectReaction`/`reflectQuotedMessageUpdate`/ + `reflectUserUpdate`/`applyMessageDeletionForUser`). +- **Navigation IS kept:** `PinnedMessagePaginationOptions` supports `id_around`, so `jumpToMessage` (and + `jumpToTheLatestMessage`, i.e. most-recently-pinned) are meaningful for pinned and remain exposed. + +**MUST NOT expose or touch read/unread state.** Read/unread + delivery receipts belong to the channel and thread +message timelines — never to a _subset_ like pinned. Concretely, `PinnedMessagePaginator`: + +- overrides `postQueryReconcile` so the first-page reconcile does **not** call `seedUnreadSnapshot` (the base + seeds the unread snapshot from `channel.state.read` on the first page — wrong for pinned); +- does **not** surface `unreadStateSnapshot`, `seedUnreadSnapshot`/`setUnreadSnapshot`, `jumpToTheFirstUnreadMessage`, + or the `unreadReferencePolicy` option (unread-coupled — as opposed to the plain `id_around` `jumpToMessage`, which stays); +- is **not** wired into `MessageReceiptsTracker` (the tracker resolves read/delivered cursors via the _channel's_ + `messagePaginator.findItemByTimestamp`; the pinned paginator must never be a receipts source or target). + +**This read/unread carve-out is the strongest argument for the alternative factoring:** rather than subclassing +`MessagePaginator` and _suppressing_ its unread surface, extract the unread/read-snapshot concern out of +`MessagePaginator` into a separate composable piece (mixin or companion), leaving a clean message-interval base +(interval store + `ingestItem` + `reflect*` + `jumpToMessage`/`jumpToTheLatestMessage` navigation). Then the +channel/thread **main** paginators compose the unread concern, and `PinnedMessagePaginator` extends the clean base +and simply never gets it — no suppression needed, no accidental `channel.state.read` coupling. + +- _Recommended:_ the extraction (clean, prevents unread leaking into pinned by construction). +- _Lower-effort fallback:_ `extends MessagePaginator` + explicitly override/no-op the unread surface listed above + (works, but the unread API technically still exists on the pinned instance and must be kept inert). + +**Pin/unpin comes for free:** `matchesFilter({ pinned: true })` makes `ingestItem` auto-add on pin and +auto-remove on unpin — so the channel `message.new/updated/deleted` handlers just also feed +`pinnedMessagesPaginator.ingestItem(...)` (and `channel.truncated` prunes via `truncate`); reactions go through +its `reflectReaction`. No bespoke pin/unpin logic. + +**Follow-through:** seed from `ChannelAPIResponse.pinned_messages` on open; migrate the React pinned views +(`PinnedMessagesView`, `usePinnedMessagesSearch`, `usePinnedMessagesCount`, `slotBinding`) to the paginator; +delete `channel.state.pinnedMessages` + `addPinnedMessage(s)`/`removePinnedMessage`. This then lets Task 11's +"shrink to pinned-only" methods (`_updateMessage`/`updateUserMessages`/`deleteUserMessages`) be **deleted +outright**. Breaking public-API change → Task 12 (exports/semver) + Task 14 (docs). + +--- + +## Execution Order + +``` +Phase 1 (Parallel): +├── Task 1: Paginator capabilities +└── Task 8: React readers + +Phase 2 (After Task 1): +├── Task 2: channel.ts seeding + readers +├── Task 3: CooldownTimer +├── Task 5: client.ts _updateUserReferences +├── Task 6: thread.ts reply source +└── Task 15: Offline (only if in scope — decisions.md #1) + +Phase 3 (After Task 2 + Task 6): +├── Task 4: messageDelivery (needs Task 1 + Task 6) +└── Task 7: channel.ts getReplies decouple (needs Task 2 + Task 6) + +Phase 4 (After all readers: Tasks 3,4,5,7,8 [+15 if in scope]): +├── Task 9: channel.ts stop dual-write +└── Task 10: client.ts stop dual-write (needs Task 5 + Task 9) + +Phase 5 (After Tasks 9,10 [+15]): +└── Task 11: Delete ChannelState storage + utils + +Phase 6 (After Task 11): +└── Task 12: Types & exports + +Phase 7 (After Task 12): +├── Task 13: Tests +└── Task 14: Docs +``` + +## File Ownership Summary + +| Task | Creates/Modifies | +| ---- | ------------------------------------------------------------------------------------------------- | +| 1 | `src/pagination/paginators/MessagePaginator.ts`, `src/pagination/paginators/BasePaginator.ts` | +| 2 | `src/channel.ts` (chain 1/3) | +| 3 | `src/CooldownTimer.ts` | +| 4 | `src/messageDelivery/MessageDeliveryReporter.ts`, `src/messageDelivery/MessageReceiptsTracker.ts` | +| 5 | `src/client.ts` (chain 1/2) | +| 6 | `src/thread.ts` | +| 7 | `src/channel.ts` (chain 2/3) | +| 8 | React repo: `MessageBounceContext.tsx`, `ChannelListItem/*`, `useMarkRead.ts`, `CLAUDE.md` | +| 9 | `src/channel.ts` (chain 3/3) | +| 10 | `src/client.ts` (chain 2/2) | +| 11 | `src/channel_state.ts`, `src/utils.ts` | +| 12 | `src/types.ts`, `src/index.ts`, `src/channel_state.ts` (types) | +| 13 | `test/unit/*`, `test/typescript/response-generators/message.js` | +| 14 | `CLAUDE.md`, `stream-chat-react/CLAUDE.md`, `developers/*` | +| 15 | `src/offline-support/offline_support_api.ts`, `src/pagination/paginators/BasePaginator.ts` | + +> Note: `src/channel.ts` (Tasks 2→7→9) and `src/client.ts` (Tasks 5→10) are serialized chains — one +> agent at a time per the make-plans same-file rule. `BasePaginator.ts` is touched by Task 1 and (if +> in scope) Task 15 → order 15 after 1. diff --git a/specs/remove-legacy-channelstate-messages/spec.md b/specs/remove-legacy-channelstate-messages/spec.md new file mode 100644 index 000000000..034fbdb8e --- /dev/null +++ b/specs/remove-legacy-channelstate-messages/spec.md @@ -0,0 +1,85 @@ +# Spec — Remove legacy `ChannelState` message storage + +Status: **planned**. Repo: `stream-chat` (JS SDK), with coordinated `stream-chat-react` changes. + +## Goal + +Make `channel.messagePaginator` / `thread.messagePaginator` the **single source of truth** for loaded +messages, and delete the legacy in-memory message store on `ChannelState` (`src/channel_state.ts`): +the `messageSets` array, its `messages` / `latestMessages` accessors, the per-parent `threads` reply +record, and the whole family of set mutators/finders. Today both stores are maintained **in lockstep** +(dual-write) for the main list — every legacy writer already has a paginator twin — so the work is +mostly _removing the legacy half_ once the remaining read paths and a few paginator capability gaps +are migrated. + +## Motivation + +Two parallel message stores means double bookkeeping, drift risk, and confusion about the source of +truth (the bug that motivated this: a `watch()`-opened channel had populated `channel.state.messages` +but an empty `messagePaginator`, so the UI — which reads the paginator — showed no messages). One +store removes the class of bug entirely. + +## Scope + +> **OUT OF SCOPE (narrowed 2026-07): threads.** `thread.ts` / `Thread.state.replies` and the +> per-parent `ChannelState.threads` reply record are **excluded** from this initiative. The target is +> the **main channel message list** (`channel.state.messages` / `messageSets` / `latestMessages`) +> only. Consequently the shared `ChannelState` methods that serve both the message list _and_ threads +> (`addMessagesSorted`, `findMessage`, `updateUserMessages`, `deleteUserMessages`) are **refactored to +> keep their thread/pinned handling**, not deleted outright. + +**Removed** (main message-list concerns in `src/channel_state.ts` unless noted): +`messageSets` (L125); `get/set messages` (L164/168); `get/set latestMessages` (L177/181); +`get messagePagination` (L330); `pruneOldest`, the message-list portions of `addMessageSorted`/ +`addMessagesSorted`, `addReaction`/`removeReaction` (+ `_addReactionToState`/`_removeReactionFromState`/ +`_add|_removeOwnReaction*`), `_updateQuotedMessageReferences`/`removeQuotedMessageReferences`, +`_updateMessage`, `_addToMessageList`, `removeMessage`/`removeMessageFromArray`, the message-list +portions of `updateUserMessages`/`deleteUserMessages`, `filterErrorMessages`, `initMessages`, +`loadMessageIntoState`, the message-list portion of `findMessage`/`findMessageByTimestamp`, +`switchToMessageSet` + private set-geometry helpers (L62-92); `src/utils.ts` `messageSetPagination` +(L1069+); `type MessageSet`, `type MessageSetType`, `isLatestMessageSet` (`src/types.ts`). + +**Stays** (non-message-list data on `ChannelState`): watchers, typing (+ the typing-only `clean()`), +read state, members/`member_count`, own capabilities, muted users, **`pinnedMessages`**, the +**`threads` reply record (L108)** and its mutations (threads out of scope), `membership`, +`unreadCount`, `last_message_at`, `isUpToDate`. Undecided: `pending_messages` (see `decisions.md`). + +## Blocking gaps (paginator is not yet an authoritative superset) + +1. Derived reads still on legacy: `channel.lastMessage()` (L1389), `countUnread`/`countUnreadMentions` + (L1706/L1728), `_extendEventWithOwnReactions` `findMessage` (L2895), `deleteReaction` offline read + (L729), `CooldownTimer.refresh` (L105), `MessageReceiptsTracker` (L186), `MessageDeliveryReporter` + channel branch (L137), `client._updateUserReferences` (L1487). +2. **Latest-window accessor:** the paginator's _active_ interval is not always the head/latest window + (after a jump/search), so a paginator-based `lastMessage`/unread read needs a head-anchored accessor. +3. **Partial truncation:** `channel.truncated` with `truncated_at` prunes per-set by timestamp; the + paginator only supports full `clearStateAndCache()`. +4. **Seeding coverage:** `channel.query()` (direct), `channel.search()`, `loadMessageIntoState` don't + seed the paginator (only `watch()` and `hydrateActiveChannels` do). +5. **Threads:** `ChannelState.threads` (no external `src` reader) + `Thread.state.replies` (still the + maintained UI source, `thread.ts` L776 NOTE) + `Channel.getReplies` writing `state.addMessagesSorted` + (L1600). +6. **Offline DB** (`offline-support/offline_support_api.ts` L1296): replay path writes + `channel.state.addMessageSorted`; `BasePaginator.isOfflineSupportEnabled` is hardcoded `false`. + Because the storage deletion removes `addMessageSorted`, offline **must** be migrated before the + final delete (it is not optional if we delete that method) — see `decisions.md`. +7. **`isUpToDate` parity:** legacy gates `message.new` insertion on `isUpToDate` (L2428); confirm the + paginator's `isHead` routing matches. + +## Blast radius + +- **Public API (semver-major):** `src/index.ts` re-exports `channel_state` + `types`. `ChannelState` + message members, `MessageSet`, `MessageSetType`, `isLatestMessageSet` disappear/change. Encode via + Conventional Commit `feat!:` / `BREAKING CHANGE:` footer — never bump the version manually. +- **React SDK (must migrate before the JS delete):** 7 sites — `MessageBounceContext.tsx:53`, + `ChannelListItem/{utils.tsx:47,ChannelListItem.tsx:96/158,ChannelListItemUI.tsx:44}`, + `MessageList/hooks/useMarkRead.ts:8` — plus stale `CLAUDE.md` guidance in both repos. +- **Tests (JS):** ~450 refs across 7 files (`channel_state.test.js` 355, `channel.test.js` 56, + `client.test.js` 25, `CooldownTimer.test.ts` 9, offline 3, `poll_manager.test.ts` 1, response-gen 1). + +## Acceptance (whole initiative) + +- No reference to `messageSets` / `state.messages` / `state.latestMessages` / `addMessageSorted` etc. + remains in `stream-chat/src` or `stream-chat-react/src`. +- `yarn types` + `yarn lint` clean; `yarn test-unit` green vs. branch baseline. +- Deep-linked channel, jumped/searched message, and threads all render (headless vite check). diff --git a/specs/remove-legacy-channelstate-messages/state.json b/specs/remove-legacy-channelstate-messages/state.json new file mode 100644 index 000000000..9dff5d7f8 --- /dev/null +++ b/specs/remove-legacy-channelstate-messages/state.json @@ -0,0 +1,156 @@ +{ + "feature": "remove-legacy-channelstate-messages", + "status": "planned", + "worktree": { + "path": "../stream-chat-js-worktrees/remove-legacy-channelstate-messages", + "branch": "feat/remove-legacy-channelstate-messages", + "base": "feat/message-paginator-master-merge" + }, + "openDecisions": ["D1", "D2", "D3", "D4"], + "tasks": [ + { + "id": 1, + "name": "Paginator capability parity", + "status": "done", + "owner": "claude", + "dependencies": [], + "note": "Added BasePaginator.latestItems/latestItem + protected headInterval, MessagePaginator.truncate({truncatedAt}); isUpToDate<->isHead parity confirmed via tests. Full unit suite 3514 pass (+7 new), 0 fail." + }, + { + "id": 2, + "name": "channel.ts seeding + derived readers", + "status": "done", + "owner": "claude", + "dependencies": [1], + "note": "Migrated lastMessage/countUnread/countUnreadMentions to messagePaginator.latestItem(s) and deleteReaction offline read to messagePaginator.getItem; legacy dual-writes left in place. _extendEventWithOwnReactions deferred to Task 6 (thread-reply lookup needs the thread paginator). Seeding finding: gap 4 already covered by watch()+hydrateActiveChannels — search() never populated ChannelState messages, loadMessageIntoState has no callers (to be deleted), and direct channel.query() must NOT be blanket-seeded (it is the MessagePaginator's own pagination transport → re-entrancy). channel.test.js updated to seed the paginator. Full suite 3542 pass." + }, + { + "id": 3, + "name": "CooldownTimer reader", + "status": "done", + "owner": "claude", + "dependencies": [1], + "note": "CooldownTimer.refresh() reads messagePaginator.latestItems; moved messagePaginator creation before cooldownTimer in the Channel ctor (refresh() runs at construction). Fixed a Task 1 gap found here: latestItems/latestItem now fall back to the live head (logical) interval so WS message.new on a fresh (unqueried) channel is seen. CooldownTimer.test.ts seeds the paginator. Full suite 3542 pass." + }, + { + "id": 4, + "name": "messageDelivery readers (channel branch only)", + "status": "done", + "owner": "claude", + "dependencies": [1], + "note": "MessageDeliveryReporter channel branch -> messagePaginator.latestItems; added MessagePaginator.findItemByTimestamp (lowerBound over latest window) and re-pointed MessageReceiptsTracker's default locator to it. Thread branch left as-is (threads out of scope; it early-returns anyway). Moved messagePaginator creation above messageReceiptsTracker in the Channel ctor. Tests seed the paginator; message.new test messages need cid to ingest. Full suite 3543 pass (lone 301 WS error is environmental)." + }, + { + "id": 5, + "name": "client.ts _updateUserReferences reader", + "status": "done", + "owner": "claude", + "dependencies": [1], + "note": "Added MessagePaginator.reflectUserUpdate(user) (batched: patches item index + single active-window re-emit) and called it alongside legacy state.updateUserMessages in client._updateUserMessageReferences (dual-write; legacy removed in Task 10). Thread replies (thread paginators) + pinnedMessages user-ref updates deferred to Task 6 / pinned handling. Full suite 3543 pass; the lone 301 WS error is environmental." + }, + { + "id": 6, + "name": "thread.ts reply source of truth", + "status": "in-scope (folded into Task 11)", + "owner": "claude", + "dependencies": [1], + "note": "SCOPE REVERSED (user directive): channel.state.threads is to be REMOVED, not kept. Verified the Thread object already owns reply state: Thread.state.replies + its own Thread.messagePaginator, with independent subscriptions to message.new/updated/deleted + reaction.* (thread.ts:517/617/639). So channel.state.threads is a redundant legacy shadow for RENDERING. The only thing riding on it is own_reactions preservation on cross-user reactions (channelState.addReaction reads the local reply copy so another user's reaction event doesn't wipe our own_reactions). That preservation must be re-homed onto the paginators (see Task 11 note). Removal folded into Task 11." + }, + { + "id": 7, + "name": "channel.ts getReplies decouple", + "status": "in-scope (folded into Task 11)", + "owner": "claude", + "dependencies": [2], + "note": "SCOPE REVERSED (user directive): channel.getReplies (channel.ts:1578) currently calls this.state.addMessagesSorted(data.messages) to populate channel.state.threads. With channel.state.threads removed, that call is dropped — the Thread object populates its own reply store from the getReplies response. Folded into Task 11." + }, + { + "id": 8, + "name": "React readers migration", + "status": "done", + "owner": "claude", + "dependencies": [], + "note": "React readers (ChannelListItem lastMessage/preview, useMessageDeliveryStatus, useMarkRead, MessageBounceContext, useActionHandler) now read channel.messagePaginator. Fixed the receipts-ordering bug (option B): the paginator is now seeded SYNCHRONOUSLY before read-state hydration. Split BasePaginator.postQueryReconcile into an async wrapper + sync applyQueryReconcile core; added MessagePaginator.seedFirstPageSync used by channel.query() ('latest' path, before _initializeState) and client.hydrateActiveChannels() (before _initializeState, keeping the isInitialized/isActiveIntervalAtHead guard); removed the late async seeds from watch()/hydrate. Changed findItemByTimestamp from ceil to floor (last message <= target) — correct for read/delivered cursors; its only prod caller is the receipts tracker. Wired client._deleteUserMessageReference to messagePaginator.applyMessageDeletionForUser (global-ban user.messages.deleted). Fixed initClientWithChannels concurrent-setup race (Promise.all -> sequential) that cross-seeded paginators via the shared axios mock. Full suites green: JS 3544 pass/0 fail; React 2517 pass/0 fail; both types+lint clean." + }, + { + "id": 9, + "name": "channel.ts stop dual-write", + "status": "done", + "owner": "claude", + "dependencies": [3, 4, 5, 8], + "note": "SCOPE RE-SEQUENCED by the threads-stay decision (Task 6/7 out-of-scope): the shared ChannelState methods (addMessageSorted/removeMessage/deleteUserMessages/addReaction/removeReaction/_updateQuotedMessageReferences) still feed state.threads, so their legacy main-list WRITE can only be removed when messageSets is deleted and the methods are split — that is Task 11. Removing them here would break threads and be redone in Task 11. What Task 9 delivered (making the paginator authoritative for all remaining channel.ts read/behavior logic so Task 11 can delete the storage cleanly): (1) channel.truncated now uses messagePaginator.truncate({truncatedAt}) for the partial case (previously clearStateAndCache wrongly wiped the whole paginator on a partial truncate, blanking migrated readers) + clearUnreadSnapshot, keeps clearStateAndCache for the full case, and ingests the truncate system message into the paginator (Part A gap: it previously had no paginator twin). (2) _extendEventWithOwnReactions now resolves main (non-reply) messages via messagePaginator.getItem, keeping state.findMessage only for thread replies — removing the last channel.ts main-list reader. Remaining legacy main-list readers are only last_message_at (fed by addMessagesSorted, which stays until Task 11) and channel.truncated's own messageSet scan (thread/pinned pruning). Tests updated (channel.test.js truncate + own_reactions). Full suites green: JS 3544 pass/0 fail (types+lint clean); React 2517 pass/0 fail." + }, + { + "id": 10, + "name": "client.ts stop dual-write (hydrate)", + "status": "done", + "owner": "claude", + "dependencies": [5, 9], + "note": "No standalone code change — same threads-stay coupling as Task 9, plus a green-cadence constraint. (a) The paginator is already seeded in hydrateActiveChannels via seedFirstPageSync BEFORE _initializeState (added in Task 8). (b) Poll/reminder hydration already sources from the RAW ChannelAPIResponse (client.ts ~2354-2355 use the loop var channelState.messages = the API response, not channel.state.messages) — the plan's independently-feasible criterion, already satisfied. The plan's other directive ('drop _initializeState/clearMessages/messageSetPagination seeding') is NOT independently doable: _initializeState also hydrates read/members/watchers/membership/pinned + stale-thread cleanup (all needed); clearMessages + the messageSetPagination block feed the still-PUBLIC getters channel.state.messages and channel.state.messagePagination (channel_state.ts:330), which tests still read. Stopping the legacy write now (before Task 11 deletes the storage/getters and Task 13 updates those tests) would blank those getters and turn the suite red between Task 10 and Task 13, breaking the green-vs-baseline cadence. So the hydrate legacy message-list seeding removal is folded into Task 11 (with its _initializeState split) + Task 13 (test updates). Suites remain green from Task 9 (JS 3544/0, React 2517/0)." + }, + { + "id": 11, + "name": "Delete ChannelState message-list storage + utils", + "status": "in progress", + "owner": "claude", + "dependencies": [9, 10], + "subSteps": "Running in verified sub-steps. [DONE] Sub-step 1 — dead code + last React straggler: migrated stream-chat-react SearchResultItem off channel.state.loadMessageIntoState to the paginator path (it just sets searchController.focusedMessage; already performs messagePaginator.jumpToMessage — a Task 8 React-reader gap the Explore map missed because it only scanned JS src); then deleted ChannelState.loadMessageIntoState, filterErrorMessages, findMessageByTimestamp, and the now-dead private switchToMessageSet + unused isBlockedMessage import, plus their channel_state.test.js blocks. Verified green: JS 3532 pass/0 fail (types+lint clean), React 2517 pass/0 fail. [DONE] Sub-step 2 — migrated the CONSUMER assertions (message-list-as-proxy) to the paginator while storage still dual-writes. JS: channel.test.js 8 sites (truncate tests now seed via seedLatestWindow + assert latestItems; message.delete quoted-refs + 'reload doesn't wipe state' read messagePaginator.getItem/latestItems). React: mock-builders/api/markRead.ts (latestItem?.id), ChannelListItem/ScrollToLatestMessageButton/MessageList/Channel test readers -> messagePaginator.latestItem(s)/getItem. Deliberately LEFT the messageSets/messagePagination MACHINERY tests (channel.test.js ~1036-1250 & 2746-2768; ALL 21 client.test.js message-set-merge assertions) untouched — they test code being deleted, so they get removed WITH the code in sub-step 3/4 (they stay green now via dual-write). Verified green: JS 3532/0 (lint clean), React 2517/0 (types+lint clean). SCOPE EXPANDED (user directive): channel.state.threads is ALSO removed now (not kept) — so the shared methods lose BOTH their main-list and thread branches and are DELETED outright (addMessagesSorted/addMessageSorted, removeMessage, deleteUserMessages, findMessage), or stripped to pinned-only where a pinned branch exists (_updateMessage, _updateQuotedMessageReferences). Threads' reply state is fully owned by the Thread object (Thread.state.replies + Thread.messagePaginator, verified). KEY COUPLING: own_reactions preservation on cross-user reactions currently rides on channelState.addReaction reading the local copy (channel.state.messages for main, channel.state.threads for replies); with both caches gone it must be re-homed onto the paginators — the channel reaction handler preserves via channel.messagePaginator.getItem before ingest (main); the Thread reaction handler preserves via Thread.messagePaginator.getItem (reply). [PENDING] Sub-step 3 — re-home reaction own_reactions preservation onto the paginators (add a paginator reaction-merge path); delete/strip the shared ChannelState methods; remove legacy writes in channel.ts handlers (incl. getReplies thread population) + hydrate + split _initializeState; re-home last_message_at; drop channel.state.threads. [PENDING] Sub-step 4 — delete messageSets/messages/latestMessages/threads storage + getters + geometry helpers + utils.messageSetPagination; delete the machinery + thread ChannelState tests. --- ORIGINAL SCOPE: Scope narrowed to the MAIN message list: remove messageSets / messages / latestMessages / message-set pagination + geometry helpers. KEEP ChannelState.threads (per-parent replies), pinnedMessages, and pending_messages. Shared methods that serve both sets and threads (addMessagesSorted, findMessage, updateUserMessages, deleteUserMessages, addReaction, removeReaction, _updateMessage, _updateQuotedMessageReferences, removeMessage) are REFACTORED to keep their thread/pinned handling, not fully deleted. PULLED IN FROM TASK 9 (threads-stay re-sequencing): the removal of the legacy main-list WRITES in channel.ts event handlers (message.new/updated/deleted, reactions, user.messages.deleted) happens HERE, as the shared methods are split — Task 9 could not remove them without breaking threads. PULLED IN FROM TASK 10: the hydrateActiveChannels legacy message seeding removal (clearMessages + _initializeState's addMessagesSorted message branch + the messageSetPagination block) happens HERE — it feeds the still-public getters channel.state.messages / channel.state.messagePagination (channel_state.ts:330) that tests read, so removing it before the storage/getters are deleted and tests updated (Task 13) would turn the suite red. Split _initializeState so its read/members/watchers/pinned/thread-cleanup stays while the main-list write drops. Also re-home last_message_at (currently maintained by addMessagesSorted) and delete confirmed-dead code found in Part B: ChannelState.loadMessageIntoState, filterErrorMessages, findMessageByTimestamp (no src callers). NOTE: because removing the writes blanks channel.state.messages/messagePagination, Task 11 must land TOGETHER with Task 13's test updates to keep the suite green vs baseline." + }, + { + "id": 12, + "name": "Types & exports", + "status": "pending", + "owner": null, + "dependencies": [11] + }, + { + "id": 13, + "name": "Tests", + "status": "pending", + "owner": null, + "dependencies": [11, 12] + }, + { + "id": 14, + "name": "Docs", + "status": "pending", + "owner": null, + "dependencies": [12] + }, + { + "id": 15, + "name": "Offline-support migration (gated by D1)", + "status": "planned", + "owner": null, + "dependencies": [1], + "blockedBy": "D1", + "subPlan": "migrate-offline-to-messagepaginator", + "note": "Expanded into its own spec. Parity tranche (O1,O3,O5) is a required predecessor of Task 11; enhancement tranche gated on D-OFF-1." + }, + { + "id": 16, + "name": "Author (userId -> message ids) secondary index for O(1) per-user updates", + "status": "pending", + "owner": null, + "dependencies": [5], + "blocking": false, + "note": "Optimization follow-up, NOT gating Task 11. reflectUserUpdate (and later per-user ops like applyMessageDeletionForUser / deleteUserMessages migration) currently scan _itemIndex.values() (all cached messages). Maintain an author index (userId -> Set) updated on ingest/remove so these become O(user's messages) not O(all)." + }, + { + "id": 17, + "name": "Complete removal of addMessagesSorted (migrate channel.state.threads off ChannelState)", + "status": "superseded — folded into Task 11 (user directive to remove channel.state.threads now, not after Task 13)", + "owner": null, + "dependencies": [11, 13], + "note": "SUPERSEDED: the user directed removing channel.state.threads as part of Task 11 rather than deferring. See Task 6/7 (now in-scope) and Task 11. Original deferred plan retained below for reference. Sequenced AFTER Task 13. Task 11 only STRIPS addMessagesSorted/addMessageSorted (and removeMessage/deleteUserMessages/_updateMessage/_updateQuotedMessageReferences/findMessage) to their thread(+pinned) branch — it cannot delete them because they are the sole writers/consumers of channel.state.threads, the per-parent reply cache that threads-out-of-scope keeps. channel.state.threads is never read for RENDERING (thread UI reads threadManager.state / Thread.state.replies), only INTERNALLY to enrich thread-reply WS events (findMessage(id,parentId) in _extendEventWithOwnReactions; addReaction/removeReaction reply merge; deleteUserMessages/_updateMessage/_updateQuotedMessageReferences reply branches). To DELETE addMessagesSorted (and channel.state.threads) entirely: (1) move thread-reply own_reactions/reaction/quoted/delete enrichment onto the Thread object's own reply store (Thread.state.replies) — this is the deferred Task 6/7 threads migration; (2) drop the thread branches from the shared ChannelState methods and delete channel.state.threads + addMessagesSorted/addMessageSorted; (3) re-point the remaining callers (channel.getReplies at channel.ts:1578, offline_support_api) at the Thread store; (4) update/remove the ChannelState thread tests. Breaking public-API change (ChannelState is export *-ed) — fold into the Task 12 semver/exports pass or a follow-up major. Gated on reviving the Task 6/7 threads decision." + }, + { + "id": 18, + "name": "Migrate channel.state.pinnedMessages -> channel.pinnedMessagesPaginator (PinnedMessagePaginator)", + "status": "planned", + "owner": null, + "dependencies": [11], + "note": "GOAL: replace the channel.state.pinnedMessages array (+ addPinnedMessages/addPinnedMessage/removePinnedMessage + the pinned branches of _updateMessage/updateUserMessages/deleteUserMessages that Task 11 KEEPS) with a channel.pinnedMessagesPaginator. Unlike channel.state.threads, pinnedMessages is a LIVE store with real React consumers (PinnedMessagesView, usePinnedMessagesSearch, usePinnedMessagesCount, slotBinding), so this is a genuine migration, not a shadow removal.\n\nDESIGN — a dedicated PinnedMessagePaginator, NOT a bare MessagePaginator instance. Passing filters to a vanilla MessagePaginator is insufficient: buildFilters(), the sort (DEFAULT_BACKEND_SORT=created_at), and query() (channel.query({messages}) / getReplies) are methods baked with main-list/created_at assumptions, and pinned uses a DIFFERENT endpoint (channel.getPinnedMessages -> /pinned_messages) with PinnedMessagePaginationOptions (id-based cursors: id_around/id_gt(e)/id_lt(e)) and PinnedMessagesSort. Recommended: `class PinnedMessagePaginator extends MessagePaginator` overriding: buildFilters() -> { cid, pinned: true }; shouldIncludeMessageInInterval() -> !shadowed && !!pinned; item sort/comparator -> pinned_at (desc, matching legacy addPinnedMessage sortBy 'pinned_at'); query()/config.doRequest -> channel.getPinnedMessages(options, sort); getNextQueryShape + cursor derivation -> id-based (PinnedMessagePaginationOptions). NAVIGATION IS KEPT: PinnedMessagePaginationOptions supports id_around, so jumpToMessage (and jumpToTheLatestMessage = most-recently-pinned) are meaningful for pinned and stay exposed. MUST NOT expose/touch READ/UNREAD state — read/unread + delivery receipts belong to the channel + thread message timelines, never to a subset like pinned. So PinnedMessagePaginator: (a) overrides postQueryReconcile so the first-page reconcile does NOT call seedUnreadSnapshot (the base seeds from channel.state.read on first page — wrong for pinned); (b) does NOT surface unreadStateSnapshot / seedUnreadSnapshot / setUnreadSnapshot / jumpToTheFirstUnreadMessage / the unreadReferencePolicy option (unread-coupled — the plain id_around jumpToMessage stays); (c) is NOT wired into MessageReceiptsTracker (the tracker resolves cursors via the CHANNEL's messagePaginator.findItemByTimestamp; pinned must never be a receipts source/target). This read/unread carve-out is the strongest argument for the ALTERNATIVE factoring: instead of subclassing MessagePaginator and suppressing its unread surface, EXTRACT the unread/read-snapshot concern out of MessagePaginator into a composable mixin/companion, leaving a clean message-interval base (interval + ingestItem + reflect* + jumpToMessage/jumpToTheLatestMessage navigation). The channel/thread MAIN paginators compose the unread concern; PinnedMessagePaginator extends the clean base and never gets it (no suppression, no accidental channel.state.read coupling). Recommended: the extraction. Lower-effort fallback: extends MessagePaginator + override/no-op the unread surface (works, but the unread API still exists on the pinned instance and must be kept inert). The reflect* helpers must be reachable by both paginators either way (they live on the shared base if extracted, or are inherited if subclassing).\n\nPIN/UNPIN — the elegant part the user intuited: matchesFilter({ pinned: true }) means ingestItem AUTO-ADDS a message when it becomes pinned and AUTO-REMOVES it when unpinned (ingestItem commits the removal when matchesFilter fails). So the channel message.new/updated/deleted handlers simply also call pinnedMessagesPaginator.ingestItem(...) (and channel.truncated prunes via truncate); reactions on pinned messages go through pinnedMessagesPaginator.reflectReaction. No bespoke pin/unpin branching.\n\nSEEDING: seed from ChannelAPIResponse.pinned_messages on channel open (mirrors the main paginator seedFirstPageSync), replacing state.addPinnedMessages(state.pinned_messages).\n\nFOLLOW-THROUGH: migrate the React consumers to read the paginator state; delete channel.state.pinnedMessages + its methods; this then lets Task 11's 'shrink to pinned-only' methods (_updateMessage/updateUserMessages/deleteUserMessages) be DELETED outright rather than kept. Breaking public-API change (ChannelState.pinnedMessages + methods are export *-ed) -> Task 12 semver/exports + Task 14 docs. Sequenced after Task 11 (independent of the message-list removal, but shares the reflect* infrastructure)." + } + ] +} diff --git a/specs/retained-items/spec.md b/specs/retained-items/spec.md new file mode 100644 index 000000000..65c7fc663 --- /dev/null +++ b/specs/retained-items/spec.md @@ -0,0 +1,174 @@ +# Retained items — list members not delivered by pagination + +Status: **implemented** (2026-07). Scope: `stream-chat-js` (`BasePaginator` + a thin +`ChannelPaginatorsOrchestrator` pass-through) and `stream-chat-react` (hooks + example). + +> **Shipped design = SEPARATE STORE, not a merge.** The body below (Ordering / Invariants / +> Entity-storage sections) was written for an earlier "merge retained items into the paginated +> `state.items`" model that was **abandoned**. Retained items are NOT merged into the paginated +> list; they live in their own reactive store and the UI renders them wherever it likes. This +> avoids provenance ledgers, offset-corruption, and dedup entirely. + +Shipped: + +- **`stream-chat-js` — `BasePaginator` (flat mode):** single `_itemIndex` owns entities; the + paginated `state.items` is unchanged (pure server list). Retained membership is a separate + reactive `retainedState: StateStore<{ itemIds: string[] }>` (ids only, entities resolved via + `getItem`). API: `retainItem(item, { onDuplicateRetrieved? })` / `releaseItem(id)` / + `isRetained(id)`; `PaginatorOptions.onDuplicateRetrieved` (`keepRetained` default). Lifecycle: + a retained item that stops matching the filter (or is removed) drops from the store. Pagination + is untouched (retained items are never in `state.items`). +- **`ChannelPaginatorsOrchestrator`:** `retainChannel(channel, opts?)` / `releaseChannel(cid)` — + data-semantic, delegate to the owning paginator(s) (ownership resolver picks the owner). +- **`stream-chat-react`:** `useChannelPaginatorState(paginator)` (reactive paginated view) and + `useRetainedChannels(paginator)` (retained ids → entities, sorted by `effectiveComparator`). +- **Example:** `WorkspaceUrlSync` restore calls `orchestrator.retainChannel`; the channel nav + renders a pinned retained section per paginator via `useRetainedChannels`. + +Verified: full JS unit suite green (+7 retained-items tests); deep-linking a past-page-1 channel +shows it in the pinned section and opens it, with the paginated list/offset untouched. + +Related SDK fix (message-paginator gap, not retained-items specific): `Channel.watch()` now seeds the +channel's `messagePaginator` with the first (latest) page it fetches, mirroring +`client.hydrateActiveChannels()` on the `queryChannels()` path. Previously only list-queried channels +had a seeded message paginator; a channel opened via `watch()` alone (deep-link restore, search +result, new DM) rendered an empty `MessageList` until a later channel-list query re-seeded it. See +`channel.ts` `watch()`. + +Ownership at `retainChannel` time depends on `matchesFilter`, which reads channel state +(`state.members`, membership, mute status). A channel absent from every loaded page is an unwatched +stub whose state is empty, so retaining it right away would match only the empty-filter fallback +(`channels:opened`), never a data-dependent list like `channels:default`. The example therefore +**watches the channel before retaining** (`WorkspaceUrlSync.resolveBinding`) — the same single watch +`` would issue, moved earlier — so ownership resolves into the real owning list. The retained +section then renders for the **active** paginator only (`RetainedChannels paginator={activePaginator}`). + +## Problem + +An item can enter a list by a route **other than pagination** — a `?channel=` deep-link restore, a +search result, a freshly created DM, or a WS-ingested channel. Such an item may live past the +loaded page, so a first-page (re)query replaces the list and drops it. We need a first-class, +data-layer way to say "this item belongs in the list regardless of whether pagination delivered it" +— without corrupting pagination and without UI concepts bleeding into the SDK. + +## Two orthogonal axes (core idea) + +Presence and order are **separate concerns** and must not be conflated (that was the "pin" mistake): + +- **Membership** (new) — _is the item in the list?_ Presence for items not delivered by pagination. +- **Ordering** (`boost` + `sortComparator`, unchanged) — _where does it rank among items that are in + the list?_ Boost never creates presence; it only reorders items already present. + +"Deep-linked channel shown at the top" is therefore **membership + boost**, not a third concept. +`boost` is not "retain without TTL" — TTL is incidental; the real difference is the axis (rank vs +presence). + +## Membership API (data-semantic, on `BasePaginator`) + +No UI verbs in the client. The paginator exposes: + +```ts +retainItem(item: T, opts?: { onDuplicateRetrieved?: 'keepRetained' | 'dropRetained' }): void; +releaseItem(id: string): void; +isRetained(id: string): boolean; +// config default: +// PaginatorOptions.onDuplicateRetrieved?: 'keepRetained' | 'dropRetained' (default 'keepRetained') +``` + +"The user opened a channel (URL / search / DM)" is an **app** intent that _maps to_ `retainItem`. +That mapping lives in the app (and, optionally, a thin data-semantic pass-through on the +orchestrator that routes to the owning paginator — e.g. `retainChannel`/`releaseChannel`, still not +`open`/`close`). The SDK stays UI-agnostic. + +## Provenance: an item can hold two memberships at once + +An item may be **paginated** (delivered by a server page) and/or **retained** (declared via +`retainItem`). The paginator tracks which ids arrived via a server page. This ledger is what lets +`releaseItem` know whether an item survives on its own. + +## `onDuplicateRetrieved` — dedup resolution (the only real policy) + +When the _same_ item is both retained and paginated, that's a duplicate. Display is **always** +deduped (shown once) and offset/cursor **always** ignore the retained set — automatic, not policy. +The single choice is what happens to the **retained record**: + +- **`keepRetained`** (default) — retention stands. If a later re-query's first page no longer + includes the item, retention still keeps it. The library does not silently undo an explicit + declaration. +- **`dropRetained`** — retention was a _bridge_ until pagination caught up; once a page covers the + item, drop it from the retained set so it behaves like any ordinary paginated member thereafter. + +Both produce the **identical list right now**. They diverge only on a _future_ re-query that no +longer returns the item — so the policy is purely "how durable is this retention." (The earlier name +`onPaginationOverlap` was misleading — it named the trigger, not the decision; this is a +deduplication resolution, hence `onDuplicateRetrieved`.) + +## Ordering of retained items + +- Retained items are ordered by the **paginator's own sort** — `effectiveComparator` = `boost` + first, then `sortComparator` (built from the paginator's `sort` param). With nothing boosted, + `effectiveComparator` collapses to `sortComparator`, so retained items sort exactly like paginated + ones. +- Each retained item is **inserted into the displayed list at its comparator position** (the same + boost-aware binary-search insert `ingestItem` uses) over the server-ordered items, deduped — the + paginated items are **not** globally re-sorted, so we never diverge from server order. +- **Boost is the explicit override** to lift a specific retained item to the top (the deep-link + "active channel at top" case), independent of its sort key. + +### Honest caveat (inherent, not a bug) + +A retained item that isn't paginated yet has an **unknown true position** — only the loaded window +is known. So by sort key it can only be placed _among the loaded items_; if it truly belongs below +the loaded window it lands at the bottom of that window (best effort). Boost overrides this when a +deterministic top position is wanted. (This is why the `MessagePaginator` logical-head/tail model +does **not** transfer to channels: channel order is UI-state-dependent, so a sort-derived slot is +both wrong and useless — boost-to-top is the channel-appropriate answer.) + +## Invariants + +- **Display** = union(paginated, retained), deduped, ordered by `effectiveComparator` (retained + items merged into the server-ordered list at their comparator position). +- **Pagination** — offset/cursor derive from the **paginated set only**. Already true in + `BasePaginator`: `postQueryReconcile` advances `offset` by the _raw server page_ length, not the + displayed array length — so retained items never shift where the next page starts. +- **Automatic removal happens only on filter/lifecycle** — a retained item is shown iff it still + matches the paginator's filter (archived/muted out, deleted → drops). It is **never** auto-removed + merely because pagination reached it — that is exactly what `dropRetained` opts into, explicitly. +- **Scope:** flat-list mode (`ChannelPaginator`). Interval-storage paginators (`MessagePaginator`) + model out-of-range items via logical intervals and are out of scope for retention. + +## Entity storage — reuse `_itemIndex` (flat mode must populate it) + +Entities live in **one** place: the paginator's `_itemIndex` (id → entity). `retainedItems` is then +just an **ordered array of ids** (kept in `effectiveComparator` order), not a second copy of the +entities. `releaseItem`/`isRetained` operate on ids; display resolves ids → entities via +`_itemIndex`. + +Today this store is **not** available to `ChannelPaginator`: `_itemIndex` is allocated in every +paginator (BasePaginator ctor) but only written in **interval mode** (`ingestPage` and the +interval branch of `ingestItem`). So this feature requires: + +- **Flat mode populates `_itemIndex`** — write entities on query reconcile and `ingestItem`, remove + on removal — so `getItem(id)` works for channels. +- **Decouple entity-store from interval-storage.** `_usesItemIntervalStorage = !!itemIndex` conflates + "an index exists" with "use interval storage," but they're separable: the fallback index is + created regardless, so flat mode can use it as a plain id map **without** enabling intervals. + Channels stay flat; retention gets an entity store. + +## Implementation sketch (for when we build it) + +- `BasePaginator`: + - Maintain `_itemIndex` in flat mode (see above) as the single entity store. + - `retainedItemIds: string[]` (or a Set kept sorted for display) — ids only, ordered by + `effectiveComparator`; entities resolved via `_itemIndex`. + - a server-provenance id set, `retainItem` / `releaseItem` / `isRetained`, and a display-assembly + step that merges retained ids into the server-ordered list by `effectiveComparator`, deduped. + Re-applied on every reconcile so retention survives a first-page replace. `onDuplicateRetrieved` config + + per-call override drives whether a paginated duplicate clears the retained record. +- `ChannelPaginatorsOrchestrator`: optional thin `retainChannel(channel)` / `releaseChannel(cid)` + that resolve the owning paginator(s) and delegate — data-semantic, no UI verbs. +- Consumer (stream-chat-react example `Sync.tsx`): map URL/thread restore intent → `retainChannel`; + boost the restored channel if "top" placement is desired. +- Tests: retention survives first-page reconcile; offset unaffected by retained count; `keepRetained` + vs `dropRetained` divergence only across a re-query; filter-mismatch drops a retained item; + retained ordering follows `sortComparator` (and boost override); no-op in interval-storage mode. diff --git a/src/CooldownTimer.ts b/src/CooldownTimer.ts index 87374a71b..0840a645f 100644 --- a/src/CooldownTimer.ts +++ b/src/CooldownTimer.ts @@ -102,7 +102,7 @@ export class CooldownTimer extends WithSubscriptions { const canSkipCooldown = (own_capabilities ?? []).includes('skip-slow-mode'); const ownLatestMessageDate = this.findOwnLatestMessageDate({ - messages: this.channel.state.latestMessages, + messages: this.channel.messagePaginator.latestItems, }); if ( diff --git a/src/channel.ts b/src/channel.ts index 238601d9a..5f3e777aa 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -241,13 +241,16 @@ export class Channel { compositionContext: this, }); + // Created before MessageReceiptsTracker and CooldownTimer: both read the message paginator + // (receipts resolve read cursors via findItemByTimestamp; CooldownTimer.refresh reads the + // latest window at construction). + this.messagePaginator = new MessagePaginator({ channel: this }); + this.messageReceiptsTracker = new MessageReceiptsTracker({ channel: this }); this.messageReceiptsTracker.registerSubscriptions(); this.cooldownTimer = new CooldownTimer({ channel: this }); - this.messagePaginator = new MessagePaginator({ channel: this }); - this.messageOperations = new MessageOperations({ ingest: (m) => this.messagePaginator.ingestItem(m), get: (id) => this.messagePaginator.getItem(id), @@ -726,7 +729,7 @@ export class Channel { try { const offlineDb = this.getClient().offlineDb; if (offlineDb) { - const message = this.state.messages.find(({ id }) => id === messageID); + const message = this.messagePaginator.getItem(messageID); const reaction = { created_at: '', updated_at: '', @@ -1384,19 +1387,9 @@ export class Channel { * @return {ReturnType | undefined} Description */ lastMessage(): LocalMessage | undefined { - // get last 5 messages, sort, return the latest - // get a slice of the last 5 - let min = this.state.latestMessages.length - 5; - if (min < 0) { - min = 0; - } - const max = this.state.latestMessages.length + 1; - const messageSlice = this.state.latestMessages.slice(min, max); - - // sort by pk desc - messageSlice.sort((a, b) => b.created_at.getTime() - a.created_at.getTime()); - - return messageSlice[0]; + // The paginator keeps its latest (head) window sorted, so its head edge is the newest message. + // (Replaces the legacy "slice last 5 of state.latestMessages + re-sort" heuristic.) + return this.messagePaginator.latestItem; } /** @@ -1518,6 +1511,10 @@ export class Channel { this.data = state.channel; this._syncStateFromChannelData(this.data, previousData); + // The message paginator is seeded synchronously inside query() (before read-state hydration), + // so a channel opened via watch() alone — a deep-link restore, a search result, a freshly + // created DM — already has its latest page loaded here. + this._client.logger( 'info', `channel:watch() - started watching channel ${this.cid}`, @@ -1685,10 +1682,10 @@ export class Channel { */ countUnread(lastRead?: Date | null) { if (!lastRead) return this.state.unreadCount; - // todo: prevent finding the latest message set on each iteration let count = 0; - for (let i = 0; i < this.state.latestMessages.length; i += 1) { - const message = this.state.latestMessages[i]; + const latestMessages = this.messagePaginator.latestItems; + for (let i = 0; i < latestMessages.length; i += 1) { + const message = latestMessages[i]; if (message.created_at > lastRead && this._countMessageAsUnread(message)) { count++; } @@ -1706,8 +1703,9 @@ export class Channel { const userID = this.getClient().userID; let count = 0; - for (let i = 0; i < this.state.latestMessages.length; i += 1) { - const message = this.state.latestMessages[i]; + const latestMessages = this.messagePaginator.latestItems; + for (let i = 0; i < latestMessages.length; i += 1) { + const message = latestMessages[i]; if ( this._countMessageAsUnread(message) && (!lastRead || message.created_at > lastRead) && @@ -1813,6 +1811,25 @@ export class Channel { }); } + // Seed the message paginator with the first (latest) page BEFORE _initializeState, which + // hydrates the read state and (via MessageReceiptsTracker) resolves read/delivered cursors + // against this paginator. Seeding first guarantees the tracker sees a populated timeline; a + // later async seed would run after the reconcile and mislabel delivery status. Only the + // latest-page open paths (watch/create) pass 'latest' — the paginator's own pagination queries + // use 'current' and must not be reseeded as a first page here. + if (messageSetToAddToIfDoesNotExist === 'latest' && Array.isArray(state.messages)) { + const requestedPageSize = + options?.messages?.limit ?? DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE; + // Pass the query's message pagination options through: a channel can be opened AROUND a + // message (id_around / created_at_around), in which case the fetched page is a jump window, + // not the latest page — the paginator must reconcile it with jump semantics. + this.messagePaginator.seedFirstPageSync( + state.messages.map(formatMessage), + requestedPageSize, + options?.messages, + ); + } + // add any messages to our channel state const { messageSet, filteredMessageIds } = this._initializeState( state, @@ -2501,7 +2518,8 @@ export class Channel { break; case 'channel.truncated': if (event.channel?.truncated_at) { - const truncatedAt = +new Date(event.channel.truncated_at); + const truncatedAtDate = new Date(event.channel.truncated_at); + const truncatedAt = +truncatedAtDate; channelState.messageSets.forEach((messageSet, messageSetIndex) => { messageSet.messages.forEach(({ created_at: createdAt, id }) => { @@ -2514,24 +2532,28 @@ export class Channel { if (truncatedAt > +createdAt) channelState.removePinnedMessage({ id } as MessageResponse); }); - channelState.unreadCount = this.countUnread( - new Date(event.channel.truncated_at), - ); + channelState.unreadCount = this.countUnread(truncatedAtDate); + // Partial truncation: keep messages newer than the cutoff. clearStateAndCache would wipe + // the whole paginator (readers now source from it), so use the partial truncate. The + // channel-wide read/unread context is reset by the truncation, so drop the unread snapshot + // too (clearStateAndCache did this for the full-truncate branch). + this.messagePaginator.truncate({ truncatedAt: truncatedAtDate }); + this.messagePaginator.clearUnreadSnapshot(); } else { channelState.clearMessages(); channelState.unreadCount = 0; + this.messagePaginator.clearStateAndCache(); } // system messages don't increment unread counts if (event.message) { channelState.addMessageSorted(event.message); + this.messagePaginator.ingestItem(formatMessage(event.message)); if (event.message.pinned) { channelState.addPinnedMessage(event.message); } } - this.messagePaginator.clearStateAndCache(); - break; case 'member.added': case 'member.updated': { @@ -2632,9 +2654,12 @@ export class Channel { case 'reaction.new': if (event.message && event.reaction) { const { message, reaction } = event; + // channelState.addReaction still runs for its pinnedMessages side-effect (dropped in the + // storage-removal step); the paginator's own_reactions preservation is now re-homed onto + // messagePaginator.reflectReaction (thread replies are handled by the Thread object). event.message = channelState.addReaction(reaction, message) as MessageResponse; if (!event.message?.parent_id) { - this.messagePaginator.ingestItem(formatMessage(event.message)); + this.messagePaginator.reflectReaction({ message: event.message, reaction }); } } break; @@ -2643,7 +2668,11 @@ export class Channel { const { message, reaction } = event; event.message = channelState.removeReaction(reaction, message); if (event.message && !event.message.parent_id) { - this.messagePaginator.ingestItem(formatMessage(event.message)); + this.messagePaginator.reflectReaction({ + message: event.message, + reaction, + removed: true, + }); } } break; @@ -2657,7 +2686,11 @@ export class Channel { true, ) as MessageResponse; if (!event.message?.parent_id) { - this.messagePaginator.ingestItem(formatMessage(event.message)); + this.messagePaginator.reflectReaction({ + enforceUnique: true, + message: event.message, + reaction, + }); } } break; @@ -2881,7 +2914,12 @@ export class Channel { if (!event.message) { return; } - const message = this.state.findMessage(event.message.id, event.message.parent_id); + // Main (non-reply) messages are owned by the message paginator; thread replies still live in + // ChannelState (threads are OUT OF SCOPE and stay there). Resolve each from its own source so + // this no longer reads the legacy main message list. + const message = event.message.parent_id + ? this.state.findMessage(event.message.id, event.message.parent_id) + : this.messagePaginator.getItem(event.message.id); if (message) { event.message.own_reactions = message.own_reactions; } diff --git a/src/channel_state.ts b/src/channel_state.ts index 584563546..3b656e183 100644 --- a/src/channel_state.ts +++ b/src/channel_state.ts @@ -15,7 +15,6 @@ import { deleteUserMessages as _deleteUserMessages, addToMessageList, formatMessage, - isBlockedMessage, } from './utils'; import { DEFAULT_MESSAGE_SET_PAGINATION } from './constants'; import { StateStore } from './store'; @@ -980,24 +979,6 @@ export class ChannelState { }); }; - /** - * filterErrorMessages - Removes error messages from the channel state. - * - */ - filterErrorMessages() { - const filteredMessages = this.latestMessages.filter( - (message) => message.type !== 'error', - ); - - const blockedMessages = this.latestMessages.filter(isBlockedMessage); - // We need to hard delete the blocked messages from the offline database. - for (const message of blockedMessages) { - this._channel.getClient().offlineDb?.hardDeleteMessage({ id: message.id }); - } - - this.latestMessages = filteredMessages; - } - /** * clean - Remove stale data such as users that stayed in typing state for more than 5 seconds */ @@ -1036,55 +1017,6 @@ export class ChannelState { ]; } - /** - * loadMessageIntoState - Loads a given message (and messages around it) into the state - * - * @param {string} messageId The id of the message, or 'latest' to indicate switching to the latest messages - * @param {string} parentMessageId The id of the parent message, if we want load a thread reply - * @param {number} limit The page size if the message has to be queried from the server - */ - async loadMessageIntoState( - messageId: string | 'latest', - parentMessageId?: string, - limit = 25, - ) { - let messageSetIndex: number; - let switchedToMessageSet = false; - let loadedMessageThread = false; - const messageIdToFind = parentMessageId || messageId; - if (messageId === 'latest') { - if (this.messages === this.latestMessages) { - return; - } - messageSetIndex = this.messageSets.findIndex((s) => s.isLatest); - } else { - messageSetIndex = this.findMessageSetIndex({ id: messageIdToFind }); - } - if (messageSetIndex !== -1) { - this.switchToMessageSet(messageSetIndex); - switchedToMessageSet = true; - } - loadedMessageThread = - !parentMessageId || - !!this.threads[parentMessageId]?.find((m) => m.id === messageId); - if (switchedToMessageSet && loadedMessageThread) { - return; - } - if (!switchedToMessageSet) { - await this._channel.query( - { messages: { id_around: messageIdToFind, limit } }, - 'new', - ); - } - if (!loadedMessageThread && parentMessageId) { - await this._channel.getReplies(parentMessageId, { id_around: messageId, limit }); - } - messageSetIndex = this.findMessageSetIndex({ id: messageIdToFind }); - if (messageSetIndex !== -1) { - this.switchToMessageSet(messageSetIndex); - } - } - /** * findMessage - Finds a message inside the state * @@ -1109,50 +1041,6 @@ export class ChannelState { return this.messageSets[messageSetIndex].messages.find((m) => m.id === messageId); } - findMessageByTimestamp( - timestampMs: number, - parentMessageId?: string, - exactTsMatch: boolean = false, - ): LocalMessage | null { - if ( - (parentMessageId && !this.threads[parentMessageId]) || - this.messageSets.length === 0 - ) - return null; - const setIndex = this.findMessageSetByOldestTimestamp(timestampMs); - const targetMsgSet = this.messageSets[setIndex]?.messages; - if (!targetMsgSet?.length) return null; - const firstMsgTimestamp = targetMsgSet[0].created_at.getTime(); - const lastMsgTimestamp = targetMsgSet.slice(-1)[0].created_at.getTime(); - const isOutOfBound = - timestampMs < firstMsgTimestamp || lastMsgTimestamp < timestampMs; - if (isOutOfBound && exactTsMatch) return null; - - let msgIndex = 0, - hi = targetMsgSet.length - 1; - while (msgIndex < hi) { - const mid = (msgIndex + hi) >>> 1; - if (timestampMs <= targetMsgSet[mid].created_at.getTime()) hi = mid; - else msgIndex = mid + 1; - } - - const foundMessage = targetMsgSet[msgIndex]; - return !exactTsMatch - ? foundMessage - : foundMessage.created_at.getTime() === timestampMs - ? foundMessage - : null; - } - - private switchToMessageSet(index: number) { - const currentMessages = this.messageSets.find((s) => s.isCurrent); - if (!currentMessages) { - return; - } - currentMessages.isCurrent = false; - this.messageSets[index].isCurrent = true; - } - private areMessageSetsOverlap( messages1: Array<{ id: string }>, messages2: Array<{ id: string }>, diff --git a/src/client.ts b/src/client.ts index 20189ca74..34e6b419e 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1484,7 +1484,11 @@ export class StreamChat { const state = channel.state; /** update the messages from this user. */ + // Legacy dual-write retained until the ChannelState message-set removal (Task 10). The + // paginator update keeps messagePaginator-backed readers (e.g. lastMessage) in sync; thread + // replies live in thread paginators and are handled by the thread migration. state?.updateUserMessages(user); + channel.messagePaginator.reflectUserUpdate(user); } }; @@ -1512,6 +1516,11 @@ export class StreamChat { const state = channel.state; /** deleted the messages from this user. */ + channel.messagePaginator.applyMessageDeletionForUser({ + userId: user.id, + hardDelete, + deletedAt: deletedAt ?? new Date(), + }); state?.deleteUserMessages(user, hardDelete, deletedAt); } } @@ -2285,6 +2294,33 @@ export class StreamChat { c.initialized = !offlineMode; c.push_preferences = channelState.push_preferences; + const willInitialize = + skipInitialization === undefined || + !skipInitialization.includes(channelState.channel.id); + const requestedPageSize = + queryChannelsOptions?.message_limit ?? + DEFAULT_QUERY_CHANNELS_MESSAGE_LIST_PAGE_SIZE; + + // Seed the paginator BEFORE _initializeState, which hydrates read state and (via + // MessageReceiptsTracker) resolves read/delivered cursors against this paginator. Seeding + // first guarantees the tracker sees a populated timeline; a later async seed would run after + // the reconcile and mislabel delivery status. + // + // Skip the re-seed when this (shared) channel's paginator is already loaded AND the user has + // jumped to an older window (active interval is not the head): a first-page re-seed forces the + // newest page to merge into that jumped interval across the gap (missing messages in the + // middle). A cold paginator, or one still at the head (offline/at-latest), re-seeds normally so + // cursors/hasMoreTail get (re)derived and pagination keeps working. + if ( + willInitialize && + (!c.messagePaginator.isInitialized || c.messagePaginator.isActiveIntervalAtHead) + ) { + c.messagePaginator.seedFirstPageSync( + channelState.messages.map(formatMessage), + requestedPageSize, + ); + } + let updatedMessagesSet; let filteredMessageIds: string[] = []; if (skipInitialization === undefined) { @@ -2318,29 +2354,6 @@ export class StreamChat { this.polls.hydratePollCache(channelState.messages, true); this.reminders.hydrateState(channelState.messages); } - const requestedPageSize = - queryChannelsOptions?.message_limit ?? - DEFAULT_QUERY_CHANNELS_MESSAGE_LIST_PAGE_SIZE; - // Skip the re-seed when this (shared) channel's paginator is already loaded AND the user has - // jumped to an older window (active interval is not the head): a first-page re-seed forces the - // newest page to merge into that jumped interval across the gap (missing messages in the - // middle). A cold paginator, or one still at the head (offline/at-latest), re-seeds normally so - // cursors/hasMoreTail get (re)derived and pagination keeps working. - if ( - !c.messagePaginator.isInitialized || - c.messagePaginator.isActiveIntervalAtHead - ) { - c.messagePaginator.postQueryReconcile({ - direction: 'tailward', - isFirstPage: true, - queryShape: { limit: requestedPageSize }, - requestedPageSize, - results: { - items: channelState.messages.map(formatMessage), - tailward: channelState.messages[0]?.id, - }, - }); - } c.messageComposer.initStateFromChannelResponse(channelState); c.cooldownTimer.refresh(); channels.push(c); diff --git a/src/messageDelivery/MessageDeliveryReporter.ts b/src/messageDelivery/MessageDeliveryReporter.ts index 5daef646f..de332bfad 100644 --- a/src/messageDelivery/MessageDeliveryReporter.ts +++ b/src/messageDelivery/MessageDeliveryReporter.ts @@ -134,7 +134,7 @@ export class MessageDeliveryReporter { let key: string | undefined = undefined; if (isChannel(collection)) { - latestMessages = collection.state.latestMessages; + latestMessages = collection.messagePaginator.latestItems; const ownReadState = collection.state.read[ownUserId] ?? {}; lastReadAt = ownReadState?.last_read; lastDeliveredAt = ownReadState?.last_delivered_at; diff --git a/src/messageDelivery/MessageReceiptsTracker.ts b/src/messageDelivery/MessageReceiptsTracker.ts index 1ea3a60ae..ea9787528 100644 --- a/src/messageDelivery/MessageReceiptsTracker.ts +++ b/src/messageDelivery/MessageReceiptsTracker.ts @@ -183,7 +183,7 @@ export class MessageReceiptsTracker extends WithSubscriptions { this.locateMessage = locateMessage ?? ((timestampMs: number) => { - const message = this.channel.state.findMessageByTimestamp(timestampMs); + const message = this.channel.messagePaginator.findItemByTimestamp(timestampMs); return message ? { timestampMs, msgId: message.id } : null; }); } diff --git a/src/pagination/paginators/BasePaginator.ts b/src/pagination/paginators/BasePaginator.ts index 2214bba28..acf2c5f59 100644 --- a/src/pagination/paginators/BasePaginator.ts +++ b/src/pagination/paginators/BasePaginator.ts @@ -526,6 +526,35 @@ export abstract class BasePaginator { return this.state.getLatestValue().items; } + /** + * The newest loaded window of items, independent of which window is currently *active* + * (`items` follows the active interval, which may point at a jumped-to / searched window). In + * interval-storage mode this is the head-most loaded interval under the paginator's ordering + * (anchored or the live-head logical interval); in flat mode it is the full `items` list. + * + * NOTE: this deliberately uses the head-*most loaded* interval rather than requiring the + * `isHead` flag — the query/hydration seed does not reliably mark a freshly loaded latest page + * as `isHead`, so an isHead-only check would miss channel-list channels entirely. The trade-off + * is that after jumping to an older window with the latest window not loaded, this reports that + * older window as "latest" (best effort). Use for "latest"-derived reads: last message, unread + * counting, delivery candidates, channel-list previews. + */ + get latestItems(): T[] { + if (!this.usesItemIntervalStorage) return this.items ?? []; + const head = this.getHeadIntervalFromSortedIntervals(this.itemIntervals); + return head ? this.intervalToItems(head) : []; + } + + /** + * The single newest loaded item — the head pagination edge of {@link BasePaginator.latestItems}. + * `undefined` when nothing is loaded. + */ + get latestItem(): T | undefined { + if (!this.usesItemIntervalStorage) return this.items?.[0]; + const head = this.getHeadIntervalFromSortedIntervals(this.itemIntervals); + return head ? (this.getIntervalPaginationEdges(head)?.head ?? undefined) : undefined; + } + get cursor() { return this.state.getLatestValue().cursor; } @@ -600,7 +629,7 @@ export abstract class BasePaginator { params: PaginationQueryParams, ): Promise>; - abstract filterQueryResults(items: T[]): T[] | Promise; + abstract filterQueryResults(items: T[]): T[]; /** * Subclasses must return the query shape. @@ -1987,9 +2016,11 @@ export abstract class BasePaginator { /** * Falsy return value means query was not successful. * @param direction + * @param keepPreviousItems * @param forcedQueryShape * @param reset * @param retryCount + * @param silent * @param updateState */ async executeQuery({ @@ -2032,7 +2063,7 @@ export abstract class BasePaginator { retryCount, }); - return await this.postQueryReconcile({ + return this.postQueryReconcile({ direction, isFirstPage, keepPreviousItems, @@ -2043,7 +2074,7 @@ export abstract class BasePaginator { }); } - async postQueryReconcile({ + postQueryReconcile({ direction, isFirstPage, keepPreviousItems, @@ -2051,7 +2082,7 @@ export abstract class BasePaginator { requestedPageSize, results, updateState = true, - }: PostQueryReconcileParams): Promise> { + }: PostQueryReconcileParams): ExecuteQueryReturnValue { this._lastQueryShape = queryShape; this._nextQueryShape = undefined; @@ -2075,7 +2106,10 @@ export abstract class BasePaginator { const resolvedTailward = tailward ?? next; stateUpdate.lastQueryError = undefined; - const filteredItems = await this.filterQueryResults(items); + // Filtering is a synchronous local predicate (see filterQueryResults), so the whole + // reconciliation runs in a single tick. The channel-open seed relies on this to populate the + // paginator synchronously (MessagePaginator.seedFirstPageSync) before read-state hydration. + const filteredItems = this.filterQueryResults(items); stateUpdate.items = filteredItems; // State-only mode: merge pages into a single list. diff --git a/src/pagination/paginators/MessagePaginator.ts b/src/pagination/paginators/MessagePaginator.ts index 13beba1fa..7688c94d6 100644 --- a/src/pagination/paginators/MessagePaginator.ts +++ b/src/pagination/paginators/MessagePaginator.ts @@ -22,7 +22,10 @@ import type { AscDesc, LocalMessage, MessagePaginationOptions, + MessageResponse, PinnedMessagePaginationOptions, + ReactionResponse, + UserResponse, } from '../../types'; import type { Channel } from '../../channel'; import { StateStore } from '../../store'; @@ -30,6 +33,7 @@ import { formatMessage, generateUUIDv4, toDeletedMessage } from '../../utils'; import { makeComparator } from '../sortCompiler'; import type { FieldToDataResolver } from '../types.normalization'; import { resolveDotPathValue } from '../utility.normalization'; +import { lowerBound } from '../utility.search'; import { ItemIndex } from '../ItemIndex'; import { deriveCreatedAtAroundPaginationFlags } from '../cursorDerivation'; import { deriveIdAroundPaginationFlags } from '../cursorDerivation/idAroundPaginationFlags'; @@ -400,6 +404,11 @@ export class MessagePaginator extends BasePaginator { + // A paginator query (BasePaginator.executeQuery) awaits the network before running its + // synchronous postQueryReconcile, which calls this on the first page. If the channel was + // disconnected while that request was in flight, reading the client below throws ("You can't + // use a channel after client.disconnect()"), so guard against that. + if (this.channel.disconnected) return; const ownUserId = this.channel.getClient().user?.id; const ownReadState = ownUserId ? this.channel.state.read[ownUserId] : undefined; if (!ownReadState) return; @@ -412,14 +421,14 @@ export class MessagePaginator extends BasePaginator, - ): Promise> { - const result = await super.postQueryReconcile(params); + ): ExecuteQueryReturnValue { + const result = super.postQueryReconcile(params); if (params.isFirstPage) { this.seedUnreadSnapshot(); @@ -427,6 +436,47 @@ export class MessagePaginator extends BasePaginator { + const cutoff = truncatedAt.getTime(); + if (Number.isNaN(cutoff)) return; + + const isOld = (item: LocalMessage | undefined) => { + const time = item?.created_at ? new Date(item.created_at).getTime() : undefined; + return typeof time === 'number' && time < cutoff; + }; + + const removedIds: string[] = []; + const survivingIntervals: AnyInterval[] = []; + // iterate from head to tail + for (const interval of this.itemIntervals) { + const edges = this.getIntervalPaginationEdges(interval); + if (!edges || !isOld(edges.tail)) { + survivingIntervals.push(interval); // oldest edge >= cutoff → nothing to drop + } else if (isOld(edges.head)) { + removedIds.push(...interval.itemIds); // newest edge < cutoff → whole interval is older + } else { + // determines the cutoff. Items are chronological, so the old ones are at the beginning of the array, + // binary-search rather than scanning every member. + const ids = interval.itemIds; + const splitIndex = lowerBound( + ids.length, + (index) => !isOld(this._itemIndex.get(ids[index])), + ); + removedIds.push(...ids.slice(0, splitIndex)); + const kept = ids.slice(splitIndex); + survivingIntervals.push( + isLogicalInterval(interval) + ? { ...interval, itemIds: kept } + : { ...interval, itemIds: kept, isTail: true, hasMoreTail: false }, + ); + } + } + + if (!removedIds.length) return; + for (const id of removedIds) this._itemIndex.remove(id); + + // No re-sort needed: `survivingIntervals` preserves the order of the already-sorted + // `itemIntervals` (we only keep/prune/drop, never reorder), and truncation removes only + // tailward items — an interval's head edge (the intervalComparator sort key) never changes. + this.setIntervals(survivingIntervals); + + // Single re-emit of the active window (setIntervals does not emit). + const active = this._activeIntervalId + ? this._itemIntervals.get(this._activeIntervalId) + : undefined; + if (active && !isLogicalInterval(active)) { + this.setActiveInterval(active); + return; + } + + // The active window was truncated away entirely. It sat below the cutoff, so it was older + // than every survivor — activate the nearest surviving window (the tail-most, i.e. oldest, + // anchored interval) rather than emitting an empty page, which would blank the message list. + const anchoredSurvivors = this.itemIntervals.filter( + (itv): itv is Interval => !isLogicalInterval(itv), + ); + + // If the active interval was truncated, we move to the neareast interval - which is the tail now + const fallback = this.getTailIntervalFromSortedIntervals(anchoredSurvivors); + if (fallback) { + this.setActiveInterval(fallback); + } else { + // Nothing loaded survived the truncation. + this.state.partialNext({ items: [] }); + } + }; + applyMessageDeletionForUser = ({ userId, hardDelete = false, @@ -961,6 +1094,132 @@ export class MessagePaginator extends BasePaginator { + const activeIds = new Set((this.items ?? []).map((m) => this.getItemId(m))); + let activeAffected = false; + for (const message of this._itemIndex.values()) { + if (message.user?.id !== user.id) continue; + this._itemIndex.setOne({ ...message, user }); + if (activeIds.has(this.getItemId(message))) activeAffected = true; + } + if (activeAffected) { + this.state.partialNext({ + items: (this.items ?? []).map((m) => this.getItem(this.getItemId(m)) ?? m), + }); + } + }; + + /** + * Apply a reaction WS event (`reaction.new` / `reaction.updated` / `reaction.deleted`) to the + * cached message. The event's `message` already carries the server-updated + * `reaction_groups` / `latest_reactions`; only `own_reactions` needs local preservation so a + * cross-user reaction does not wipe the current user's reactions. This re-homes what + * `ChannelState.addReaction` / `removeReaction` used to do off the now-removed + * `channel.state.messages` / `channel.state.threads` caches (the same logic backs the thread + * paginator via `Thread.messagePaginator`). + * + * `own_reactions` is seeded from the currently cached item (so another user's reaction keeps ours), + * falling back to the event's own_reactions when the message is not loaded — matching the legacy + * behavior where `_updateMessage` only mutated a message that existed locally. + * + * @param params + * @param {MessageResponse | LocalMessage} params.message The reaction event's message, carrying the + * server-computed `reaction_groups` / `latest_reactions`. Ingested as-is except for `own_reactions`. + * @param {ReactionResponse} params.reaction The reaction from the event. Only added to/removed from + * `own_reactions` when its `user_id` is the current user; otherwise the current user's + * `own_reactions` are left untouched. + * @param {boolean} [params.removed=false] `true` for `reaction.deleted` (remove the reaction from + * `own_reactions`); `false` for `reaction.new` / `reaction.updated` (add it). + * @param {boolean} [params.enforceUnique=false] When adding, first clear the current user's existing + * `own_reactions` so only the incoming one remains (used by `reaction.updated`, where a user's + * reaction replaces their previous one). + */ + reflectReaction = ({ + enforceUnique = false, + message, + reaction, + removed = false, + }: { + message: MessageResponse | LocalMessage; + reaction: ReactionResponse; + enforceUnique?: boolean; + removed?: boolean; + }) => { + const formatted = formatMessage(message); + const existing = this.getItem(formatted.id); + const baseOwnReactions = existing?.own_reactions ?? formatted.own_reactions ?? []; + const own_reactions = removed + ? this.removeOwnReactionOfType(baseOwnReactions, reaction) + : this.addOwnReaction(baseOwnReactions, reaction, enforceUnique); + this.ingestItem({ ...formatted, own_reactions }); + }; + + private removeOwnReactionOfType( + ownReactions: ReactionResponse[], + reaction: ReactionResponse, + ): ReactionResponse[] { + return ownReactions.filter( + (r) => r.user_id !== reaction.user_id || r.type !== reaction.type, + ); + } + + private addOwnReaction( + ownReactions: ReactionResponse[], + reaction: ReactionResponse, + enforceUnique: boolean, + ): ReactionResponse[] { + const base = enforceUnique + ? [] + : this.removeOwnReactionOfType(ownReactions, reaction); + if (this.channel.getClient().userID === reaction.user_id) { + return [...base, reaction]; + } + return base; + } + + /** + * Map a timestamp to a loaded message — the first message in the latest (head) window whose + * `created_at` is >= `timestampMs` (mirrors the legacy `ChannelState.findMessageByTimestamp` + * lower-bound search), or the newest loaded message when the timestamp is beyond it. Used by the + * receipts tracker to resolve read/delivered cursors. Searches the newest loaded window — where + * read cursors live — which is already sorted, so this is O(log n) with no re-sort. + */ + findItemByTimestamp = ( + timestampMs: number, + exactTsMatch = false, + ): LocalMessage | null => { + const items = this.latestItems; // ascending by created_at + if (!items.length) return null; + // Resolve the last message created AT OR BEFORE `timestampMs` (floor). The sole caller is + // read/delivered cursor resolution (MessageReceiptsTracker): the cursor carries the timestamp of + // the last message a participant reached, so a message created strictly after the cursor has NOT + // been reached. A ceil match (first message >= target) would over-count it — e.g. a participant + // whose read cursor predates every loaded message would be reported as having read the oldest one. + // `lowerBound` returns the first index whose created_at is strictly greater than the target, so + // the floor is the item immediately before it. + const firstAfter = lowerBound(items.length, (i) => { + const t = getMessageCreatedAtTimestamp(items[i]); + return t === null || t > timestampMs; + }); + if (firstAfter === 0) return null; // target precedes every loaded message + const found = items[firstAfter - 1]; + const foundTimestamp = getMessageCreatedAtTimestamp(found); + // A message without a resolvable created_at (e.g. an optimistic message still missing its + // server timestamp) cannot be located by timestamp. + if (foundTimestamp === null) return null; + if (!exactTsMatch) return found; + return foundTimestamp === timestampMs ? found : null; + }; + filterQueryResults = (items: LocalMessage[]) => items.filter(this.shouldIncludeMessageInInterval.bind(this)); diff --git a/src/pagination/utility.search.ts b/src/pagination/utility.search.ts index 4d1cec419..6f855013e 100644 --- a/src/pagination/utility.search.ts +++ b/src/pagination/utility.search.ts @@ -1,3 +1,27 @@ +/** + * Partition-point ("lower bound") binary search over the index range `[0, length)`. + * + * `predicate` must be **monotonic** over the range — `false` for a (possibly empty) prefix, then + * `true` for the remaining suffix. Returns the first index at which `predicate(index)` holds, or + * `length` if it never does. O(log length). + * + * Unlike `binarySearch`, this locates a *boundary* defined by a predicate rather than a specific + * item by identity — e.g. "first message at/after a timestamp" over a chronologically sorted array. + */ +export function lowerBound( + length: number, + predicate: (index: number) => boolean, +): number { + let lo = 0; + let hi = length; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (predicate(mid)) hi = mid; + else lo = mid + 1; + } + return lo; +} + export function locateOnPlateauAlternating( items: readonly T[], needle: T, diff --git a/test/unit/CooldownTimer.test.ts b/test/unit/CooldownTimer.test.ts index b976e06f5..4909cbe3c 100644 --- a/test/unit/CooldownTimer.test.ts +++ b/test/unit/CooldownTimer.test.ts @@ -2,7 +2,21 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { getClientWithUser } from './test-utils/getClient'; import { generateMsg } from './test-utils/generateMessage'; -import type { ChannelResponse, Event } from '../../src'; +import { formatMessage } from '../../src'; +import type { Channel, ChannelResponse, Event } from '../../src'; + +// CooldownTimer.refresh() derives the current user's latest message from the message paginator's +// latest (head) window, so tests seed the paginator (formatted) rather than legacy channel state. +const seedLatestWindow = ( + channel: Channel, + ...messages: ReturnType[] +) => + channel.messagePaginator.ingestPage({ + page: messages.map((m) => formatMessage(m)), + isHead: true, + isTail: true, + setActive: true, + }); describe('CooldownTimer', () => { afterEach(() => { @@ -23,7 +37,8 @@ describe('CooldownTimer', () => { }; const lastOwnMessageAt = new Date('2026-01-01T00:00:00.000Z'); - channel.state.addMessageSorted( + seedLatestWindow( + channel, generateMsg({ created_at: lastOwnMessageAt.toISOString(), updated_at: lastOwnMessageAt.toISOString(), @@ -59,7 +74,8 @@ describe('CooldownTimer', () => { own_capabilities: [], }; - channel.state.addMessageSorted( + seedLatestWindow( + channel, generateMsg({ created_at: now.toISOString(), updated_at: now.toISOString(), @@ -73,7 +89,8 @@ describe('CooldownTimer', () => { channel.data.cooldown = 0; - channel.state.addMessageSorted( + seedLatestWindow( + channel, generateMsg({ created_at: now.toISOString(), updated_at: now.toISOString(), @@ -87,7 +104,8 @@ describe('CooldownTimer', () => { channel.data.cooldown = 10; - channel.state.addMessageSorted( + seedLatestWindow( + channel, generateMsg({ created_at: now.toISOString(), updated_at: now.toISOString(), @@ -119,7 +137,8 @@ describe('CooldownTimer', () => { }; const lastOwnMessageAt = new Date('2026-01-01T00:00:00.000Z'); // 10s ago - channel.state.addMessageSorted( + seedLatestWindow( + channel, generateMsg({ created_at: lastOwnMessageAt.toISOString(), updated_at: lastOwnMessageAt.toISOString(), @@ -145,7 +164,8 @@ describe('CooldownTimer', () => { own_capabilities: ['skip-slow-mode'], }; - channel.state.addMessageSorted( + seedLatestWindow( + channel, generateMsg({ created_at: now.toISOString(), updated_at: now.toISOString(), @@ -168,7 +188,8 @@ describe('CooldownTimer', () => { // timeSince = 2s const lastOwnMessageAt = new Date('2026-01-01T00:00:08.000Z'); - channel.state.addMessageSorted( + seedLatestWindow( + channel, generateMsg({ created_at: lastOwnMessageAt.toISOString(), updated_at: lastOwnMessageAt.toISOString(), @@ -202,7 +223,8 @@ describe('CooldownTimer', () => { // timeSince = 2s const lastOwnMessageAt = new Date('2026-01-01T00:00:08.000Z'); - channel.state.addMessageSorted( + seedLatestWindow( + channel, generateMsg({ created_at: lastOwnMessageAt.toISOString(), updated_at: lastOwnMessageAt.toISOString(), @@ -236,7 +258,8 @@ describe('CooldownTimer', () => { // timeSince = 2s const lastOwnMessageAt = new Date('2026-01-01T00:00:08.000Z'); - channel.state.addMessageSorted( + seedLatestWindow( + channel, generateMsg({ created_at: lastOwnMessageAt.toISOString(), updated_at: lastOwnMessageAt.toISOString(), @@ -275,6 +298,7 @@ describe('CooldownTimer', () => { type: 'message.new', user: { id: client.userID as string }, message: generateMsg({ + cid: channel.cid, // must match the paginator filter so message.new ingests into an interval created_at: now.toISOString(), updated_at: now.toISOString(), user: { id: client.userID as string }, diff --git a/test/unit/channel.test.js b/test/unit/channel.test.js index 10b6ba175..90e79db54 100644 --- a/test/unit/channel.test.js +++ b/test/unit/channel.test.js @@ -10,12 +10,24 @@ import { mockChannelQueryResponse } from './test-utils/mockChannelQueryResponse' import { ChannelState, StreamChat } from '../../src'; import { DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE } from '../../src/constants'; import { MockOfflineDB } from './offline-support/MockOfflineDB'; -import { generateUUIDv4 as uuidv4 } from '../../src/utils'; +import { formatMessage, generateUUIDv4 as uuidv4 } from '../../src/utils'; import { describe, beforeEach, afterEach, it, expect, vi } from 'vitest'; +// Seed the channel's messagePaginator "latest" (head) window from raw generated messages. +// The unread/last-message readers now source from `messagePaginator.latestItems`/`latestItem`, +// so tests populate the paginator (formatted) rather than the legacy `state.addMessagesSorted`. +const seedLatestWindow = (channel, messages) => + channel.messagePaginator.ingestPage({ + page: messages.map((m) => formatMessage(m)), + isHead: true, + isTail: true, + setActive: true, + }); + describe('Channel count unread', function () { let lastRead; + let ignoredMessages; let user; let channel; let client; @@ -34,7 +46,7 @@ describe('Channel count unread', function () { channel.lastRead = () => lastRead; channel.data.own_capabilities = ['read-events']; - const ignoredMessages = [ + ignoredMessages = [ generateMsg({ date: '2018-01-01T00:00:00', mentioned_users: [user] }), generateMsg({ date: '2019-01-01T00:00:00' }), generateMsg({ date: '2020-01-01T00:00:00' }), @@ -115,35 +127,40 @@ describe('Channel count unread', function () { it('countUnread should return correct count', function () { expect(channel.countUnread(lastRead)).to.be.equal(0); - channel.state.addMessagesSorted([ + // ignoredMessages (shadowed/silent/muted/at-or-before lastRead) must not be counted + seedLatestWindow(channel, [ + ...ignoredMessages, generateMsg({ date: '2021-01-01T00:00:00' }), generateMsg({ date: '2022-01-01T00:00:00' }), ]); expect(channel.countUnread(lastRead)).to.be.equal(2); }); - it('countUnread should return correct count when multiple message sets are loaded into state', () => { + it('countUnread should read the latest window, not the active one', () => { expect(channel.countUnread(lastRead)).to.be.equal(0); - channel.state.addMessagesSorted([ - generateMsg({ date: '2026-01-01T00:00:00' }), - generateMsg({ date: '2026-02-01T00:00:00' }), - ]); - channel.state.addMessagesSorted( - [generateMsg({ date: '2006-01-01T00:00:00' })], - false, - true, - true, - 'new', - ); - channel.state.messageSets[0].isCurrent = false; - channel.state.messageSets[1].isCurrent = true; + // latest (head) window + channel.messagePaginator.ingestPage({ + page: [ + ...ignoredMessages, + generateMsg({ date: '2026-01-01T00:00:00' }), + generateMsg({ date: '2026-02-01T00:00:00' }), + ].map((m) => formatMessage(m)), + isHead: true, + setActive: false, + }); + // a separate, older window becomes the active (current) one + channel.messagePaginator.ingestPage({ + page: [formatMessage(generateMsg({ date: '2006-01-01T00:00:00' }))], + setActive: true, + }); expect(channel.countUnread(lastRead)).to.be.equal(2); }); it('countUnreadMentions should return correct count', function () { expect(channel.countUnreadMentions()).to.be.equal(0); - channel.state.addMessageSorted( + seedLatestWindow(channel, [ + ...ignoredMessages, generateMsg({ date: '2021-01-01T00:00:00', mentioned_users: [user, { id: 'random' }], @@ -152,28 +169,30 @@ describe('Channel count unread', function () { date: '2022-01-01T00:00:00', mentioned_users: [{ id: 'random' }], }), - ); + ]); expect(channel.countUnreadMentions()).to.be.equal(1); }); - it('countUnreadMentions should return correct count when multiple message sets are loaded into state', () => { + it('countUnreadMentions should read the latest window, not the active one', () => { expect(channel.countUnreadMentions()).to.be.equal(0); - channel.state.addMessagesSorted([ - generateMsg({ - date: '2021-01-01T00:00:00', - mentioned_users: [user, { id: 'random' }], - }), - generateMsg({ date: '2022-01-01T00:00:00' }), - ]); - channel.state.addMessagesSorted( - [generateMsg({ date: '2010-01-01T00:00:00' })], - false, - true, - true, - 'new', - ); - channel.state.messageSets[0].isCurrent = false; - channel.state.messageSets[1].isCurrent = true; + // latest (head) window contains the mention + channel.messagePaginator.ingestPage({ + page: [ + ...ignoredMessages, + generateMsg({ + date: '2021-01-01T00:00:00', + mentioned_users: [user, { id: 'random' }], + }), + generateMsg({ date: '2022-01-01T00:00:00' }), + ].map((m) => formatMessage(m)), + isHead: true, + setActive: false, + }); + // a separate, older window becomes the active (current) one + channel.messagePaginator.ingestPage({ + page: [formatMessage(generateMsg({ date: '2010-01-01T00:00:00' }))], + setActive: true, + }); expect(channel.countUnreadMentions()).to.be.equal(1); }); @@ -334,7 +353,7 @@ describe('Channel localized unread count (isLocalUnreadCountEnabled)', function const { client, channel } = setupChannel({ isLocalUnreadCountEnabled: true }); const post = vi.spyOn(client, 'post').mockResolvedValue({}); const lastMsg = generateMsg({ user: otherUser }); - channel.state.addMessagesSorted([lastMsg]); + seedLatestWindow(channel, [lastMsg]); channel.state.unreadCount = 5; channel.state.read[user.id] = { last_read: new Date('2020-01-01T00:00:00'), @@ -386,7 +405,7 @@ describe('Channel localized unread count (isLocalUnreadCountEnabled)', function const { client, channel } = setupChannel({ isLocalUnreadCountEnabled: true }); const post = vi.spyOn(client, 'post').mockResolvedValue({}); const lastMsg = generateMsg({ user: otherUser }); - channel.state.addMessagesSorted([lastMsg]); + seedLatestWindow(channel, [lastMsg]); channel.state.unreadCount = 3; delete channel.state.read[user.id]; @@ -727,8 +746,8 @@ describe('Channel _handleChannelEvent', function () { { created_at: '2021-01-01T00:03:00' }, ].map(generateMsg); - channel.state.addMessagesSorted(messages); - expect(channel.state.messages.length).to.be.equal(3); + seedLatestWindow(channel, messages); + expect(channel.messagePaginator.latestItems.length).to.be.equal(3); channel._handleChannelEvent({ type: 'channel.truncated', @@ -738,11 +757,14 @@ describe('Channel _handleChannelEvent', function () { }, }); - expect(channel.state.messages.length).to.be.equal(0); + expect(channel.messagePaginator.latestItems.length).to.be.equal(0); }); it('message.truncate clears messagePaginator unread snapshot', function () { - const cachedMessage = generateMsg({ id: 'truncate-cached-message-id' }); + const cachedMessage = generateMsg({ + date: '2020-01-01T00:00:00.000Z', + id: 'truncate-cached-message-id', + }); channel.messagePaginator.setItems({ valueOrFactory: [cachedMessage], isFirstPage: true, @@ -769,7 +791,9 @@ describe('Channel _handleChannelEvent', function () { lastReadMessageId: null, unreadCount: 0, }); - expect(channel.messagePaginator.items).toBeUndefined(); + // Partial truncate (truncated_at in the past) prunes the older-than-cutoff message; the + // emptied active window resolves to an empty item list. + expect(channel.messagePaginator.items ?? []).toEqual([]); expect(channel.messagePaginator.getItem(cachedMessage.id)).toBeUndefined(); }); @@ -780,8 +804,8 @@ describe('Channel _handleChannelEvent', function () { { created_at: '2021-01-01T00:03:00' }, ].map(generateMsg); - channel.state.addMessagesSorted(messages); - expect(channel.state.messages.length).to.be.equal(3); + seedLatestWindow(channel, messages); + expect(channel.messagePaginator.latestItems.length).to.be.equal(3); channel._handleChannelEvent({ type: 'channel.truncated', @@ -791,7 +815,7 @@ describe('Channel _handleChannelEvent', function () { }, }); - expect(channel.state.messages.length).to.be.equal(2); + expect(channel.messagePaginator.latestItems.length).to.be.equal(2); }); it('message.truncate removes pinned messages up to specified date', function () { @@ -809,9 +833,9 @@ describe('Channel _handleChannelEvent', function () { }, ].map(generateMsg); - channel.state.addMessagesSorted(messages); + seedLatestWindow(channel, messages); channel.state.addPinnedMessages(messages.filter((m) => m.pinned)); - expect(channel.state.messages.length).to.be.equal(3); + expect(channel.messagePaginator.latestItems.length).to.be.equal(3); expect(channel.state.pinnedMessages.length).to.be.equal(2); channel._handleChannelEvent({ @@ -822,7 +846,7 @@ describe('Channel _handleChannelEvent', function () { }, }); - expect(channel.state.messages.length).to.be.equal(2); + expect(channel.messagePaginator.latestItems.length).to.be.equal(2); expect(channel.state.pinnedMessages.length).to.be.equal(1); }); @@ -852,10 +876,8 @@ describe('Channel _handleChannelEvent', function () { message: { ...originalMessage, deleted_at: new Date().toISOString() }, }); - expect( - channel.state.messages.find((msg) => msg.id === quotingMessage.id).quoted_message - .deleted_at, - ).to.be.ok; + expect(channel.messagePaginator.getItem(quotingMessage.id).quoted_message.deleted_at) + .to.be.ok; }); it('message.deleted hard delete removes message from messagePaginator', function () { @@ -1616,6 +1638,7 @@ describe('Channel _handleChannelEvent', function () { type: 'message.new', user: otherUser, message: generateMsg({ + cid: channel.cid, id: messageDeliveredEvent.last_delivered_message_id, date: messageDeliveredEvent.last_delivered_at, }), @@ -1637,6 +1660,7 @@ describe('Channel _handleChannelEvent', function () { }); channel.state.read[user.id] = initialReadState; const newerMessage = generateMsg({ + cid: channel.cid, id: 'some-other-id', date: new Date(3000).toISOString(), }); @@ -1671,6 +1695,7 @@ describe('Channel _handleChannelEvent', function () { type: 'message.new', user: otherUser, message: generateMsg({ + cid: channel.cid, id: messageDeliveredEvent.last_delivered_message_id, date: messageDeliveredEvent.last_delivered_at, }), @@ -1767,6 +1792,12 @@ describe('Channel _handleChannelEvent', function () { testCases.forEach((messages) => { channel.state.addMessagesSorted(messages); + // Main (non-reply) messages are resolved from the paginator by + // _extendEventWithOwnReactions; thread replies stay resolved via channel.state. + seedLatestWindow( + channel, + messages.filter((m) => !m.parent_id), + ); const message = messages[messages.length - 1]; const eventTypes = ['message.updated', 'message.deleted']; @@ -1978,7 +2009,7 @@ describe('Channel _handleChannelEvent', function () { channel.state.read = { user: { id: 'user' }, }; - channel.state.addMessageSorted(generateMsg()); + seedLatestWindow(channel, [generateMsg()]); channel.state.addPinnedMessages([generateMsg()]); channel.state.watcher_count = 5; @@ -1987,7 +2018,7 @@ describe('Channel _handleChannelEvent', function () { expect(Object.keys(channel.state.members).length).to.be.eq(1); expect(Object.keys(channel.state.watchers).length).to.be.eq(1); expect(Object.keys(channel.state.read).length).to.be.eq(1); - expect(channel.state.messages.length).to.be.eq(1); + expect(channel.messagePaginator.latestItems.length).to.be.eq(1); expect(channel.state.pinnedMessages.length).to.be.eq(1); expect(channel.state.watcher_count).to.be.eq(5); }); @@ -2589,7 +2620,7 @@ describe('Channel lastMessage', async () => { it('should return last message - messages are in order', () => { channel.state = new ChannelState(channel); const latestMessageDate = '2018-01-01T00:13:24'; - channel.state.addMessagesSorted([ + seedLatestWindow(channel, [ generateMsg({ date: '2018-01-01T00:00:00' }), generateMsg({ date: '2018-01-01T00:02:00' }), generateMsg({ date: latestMessageDate }), @@ -2603,7 +2634,7 @@ describe('Channel lastMessage', async () => { it('should return last message - messages are out of order', () => { channel.state = new ChannelState(channel); const latestMessageDate = '2018-01-01T00:13:24'; - channel.state.addMessagesSorted([ + seedLatestWindow(channel, [ generateMsg({ date: latestMessageDate }), generateMsg({ date: '2018-01-01T00:02:00' }), generateMsg({ date: '2018-01-01T00:00:00' }), @@ -2626,8 +2657,12 @@ describe('Channel lastMessage', async () => { generateMsg({ date: '2017-11-21T00:05:33' }), generateMsg({ date: '2017-11-21T00:05:35' }), ]; - channel.state.addMessagesSorted(latestMessages); - channel.state.addMessagesSorted(otherMessages, 'new'); + // latest (head) window + a separate, older window + seedLatestWindow(channel, latestMessages); + channel.messagePaginator.ingestPage({ + page: otherMessages.map((m) => formatMessage(m)), + setActive: false, + }); expect(channel.lastMessage().created_at.getTime()).to.be.equal( new Date(latestMessageDate).getTime(), @@ -2974,8 +3009,9 @@ describe('delete reaction flow', () => { // trick the channel into being initialized channel.initialized = true; - // Add a fake message to state for reaction deletion optimistic update in the db - channel.state.messages.push({ id: messageId }); + // Add a fake message to the paginator for reaction-deletion optimistic update in the db + // (channel.deleteReaction now resolves the message via messagePaginator.getItem). + channel.messagePaginator.ingestItem({ id: messageId }); loggerSpy = vi.spyOn(client, 'logger').mockImplementation(vi.fn()); queueTaskSpy = vi.spyOn(client.offlineDb, 'queueTask').mockResolvedValue({}); diff --git a/test/unit/channel_state.test.js b/test/unit/channel_state.test.js index 91761f8f1..40f3914cd 100644 --- a/test/unit/channel_state.test.js +++ b/test/unit/channel_state.test.js @@ -295,36 +295,6 @@ describe('ChannelState addMessagesSorted', function () { expect(state.latestMessages[2].id).to.be.equal('14'); }); - it('should remove blocked messages from the latest messages from the offline database', () => { - state.addMessagesSorted( - [ - generateMsg({ - id: '12', - date: toISOString(1200), - type: 'error', - moderation_details: { action: 'MESSAGE_RESPONSE_ACTION_REMOVE' }, - }), - generateMsg({ - id: '13', - date: toISOString(1300), - type: 'error', - moderation: { action: 'remove' }, - }), - generateMsg({ id: '14', date: toISOString(1400) }), - ], - false, - false, - true, - 'latest', - ); - expect(state.latestMessages.length).to.be.equal(3); - state.filterErrorMessages(); - expect(state.latestMessages.length).to.be.equal(1); - expect(client.offlineDb.hardDeleteMessage).toHaveBeenCalledTimes(2); - expect(client.offlineDb.hardDeleteMessage).toHaveBeenCalledWith({ id: '12' }); - expect(client.offlineDb.hardDeleteMessage).toHaveBeenCalledWith({ id: '13' }); - }); - it('adds message page sorted', () => { // load first page state.addMessagesSorted( @@ -2133,141 +2103,6 @@ describe('ChannelState own capabilities store', () => { }); }); -describe('loadMessageIntoState', () => { - let state; - - beforeEach(() => { - const client = new StreamChat(); - client.userID = 'userId'; - const channel = new Channel(client, 'type', 'id', {}); - client._addChannelConfig({ cid: channel.cid, config: {} }); - state = new ChannelState(channel); - }); - - it('should do nothing if message is available locally in the current set', async () => { - state.addMessagesSorted([generateMsg({ id: '8' })], false, true, true, 'latest'); - state.addMessagesSorted([generateMsg({ id: '5' })], false, true, true, 'new'); - await state.loadMessageIntoState('8'); - - expect(state.messageSets[0].isCurrent).to.be.equal(true); - }); - - it('should switch message sets if message is available locally, but in a different set', async () => { - state.addMessagesSorted( - [generateMsg({ id: '8', date: toISOString(800) })], - false, - true, - true, - 'latest', - ); - state.addMessagesSorted( - [generateMsg({ id: '5', date: toISOString(500) })], - false, - true, - true, - 'new', - ); - await state.loadMessageIntoState('5'); - - expect(state.messageSets[0].isCurrent).to.be.equal(false); - expect(state.messageSets[1].isCurrent).to.be.equal(true); - }); - - it('should switch to latest message set', async () => { - state.addMessagesSorted( - [generateMsg({ id: '8', date: toISOString(800) })], - false, - true, - true, - 'latest', - ); - state.addMessagesSorted( - [generateMsg({ id: '5', date: toISOString(500) })], - false, - true, - true, - 'new', - ); - state.messageSets[0].isCurrent = false; - state.messageSets[1].isCurrent = true; - await state.loadMessageIntoState('latest'); - - expect(state.messageSets[0].isCurrent).to.be.equal(true); - }); - - it('should load message from backend and switch to the new message set', async () => { - state.addMessagesSorted([ - generateMsg({ id: '5', date: toISOString(500) }), - generateMsg({ id: '6', date: toISOString(600) }), - ]); - const newMessages = [generateMsg({ id: '8', date: toISOString(800) })]; - state._channel.query = () => { - state.addMessagesSorted(newMessages, false, true, true, 'new'); - }; - await state.loadMessageIntoState('8'); - - expect(state.messages.length).to.be.equal(1); - expect(state.messages[0].id).to.be.equal('8'); - }); - - describe('if message is a thread reply', () => { - it('should do nothing if parent message and reply are available locally in the current set', async () => { - const parentMessage = generateMsg({ id: '5', date: toISOString(500) }); - const reply = generateMsg({ id: '8', date: toISOString(800), parent_id: '5' }); - state.addMessagesSorted([parentMessage]); - state.addMessagesSorted([reply]); - - await state.loadMessageIntoState('8', '5'); - - expect(state.messages[0].id).to.be.equal(parentMessage.id); - expect(state.threads[parentMessage.id][0].id).to.be.equal(reply.id); - }); - - it('should change message set if parent message and reply are available locally', async () => { - const parentMessage = generateMsg({ id: '5', date: toISOString(500) }); - const reply = generateMsg({ id: '8', date: toISOString(800), parent_id: '5' }); - state.addMessagesSorted([parentMessage]); - state.addMessagesSorted([reply]); - const otherMessages = [generateMsg(), generateMsg()]; - state.addMessagesSorted(otherMessages, false, true, true, 'new'); - state.messageSets[0].isCurrent = false; - state.messageSets[1].isCurrent = true; - - await state.loadMessageIntoState('8', '5'); - - expect(state.messages[0].id).to.be.equal(parentMessage.id); - expect(state.threads[parentMessage.id][0].id).to.be.equal(reply.id); - }); - - it(`should load replies if parent message is available locally, but reply isn't`, async () => { - const parentMessage = generateMsg({ id: '5' }); - const reply = generateMsg({ id: '8', parent_id: '5' }); - state._channel.getReplies = () => - state.addMessagesSorted([reply], false, false, true, 'current'); - state.addMessagesSorted([parentMessage]); - - await state.loadMessageIntoState('8', '5'); - - expect(state.messages[0].id).to.be.equal(parentMessage.id); - expect(state.threads[parentMessage.id][0].id).to.be.equal(reply.id); - }); - - it('should load parent message and reply from backend, and switch to new message set', async () => { - const parentMessage = generateMsg({ id: '5', date: toISOString(500) }); - const reply = generateMsg({ id: '8', date: toISOString(800), parent_id: '5' }); - state._channel.getReplies = () => - state.addMessagesSorted([reply], false, false, true, 'current'); - state._channel.query = () => - state.addMessagesSorted([parentMessage], false, true, true, 'new'); - - await state.loadMessageIntoState('8', '5'); - - expect(state.messages[0].id).to.be.equal(parentMessage.id); - expect(state.threads[parentMessage.id][0].id).to.be.equal(reply.id); - }); - }); -}); - describe('findMessage', () => { let state; @@ -2293,11 +2128,10 @@ describe('findMessage', () => { expect(state.findMessage(messageId).id).to.eql(messageId); }); - it('message is in a different set', async () => { + it('message is in a different set', () => { const messageId = '5'; state.addMessagesSorted([generateMsg({ id: '8' })], false, true, true, 'latest'); state.addMessagesSorted([generateMsg({ id: messageId })], false, true, true, 'new'); - await state.loadMessageIntoState('5'); expect(state.findMessage(messageId).id).to.eql(messageId); }); @@ -2332,105 +2166,3 @@ describe('findMessage', () => { }); }); }); - -describe('find message by timestamp', () => { - let state; - - beforeEach(() => { - const client = new StreamChat(); - client.userID = 'userId'; - const channel = new Channel(client, 'type', 'id', {}); - client._addChannelConfig({ cid: channel.cid, config: {} }); - state = new ChannelState(channel); - }); - - it('finds the message with matching timestamp', () => { - const expectedFoundMsg = generateMsg({ - id: '2', - created_at: toISOString(200), - }); - state.addMessagesSorted([ - generateMsg({ id: '12', created_at: toISOString(1200) }), - generateMsg({ id: '13', created_at: toISOString(1300) }), - generateMsg({ id: '14', created_at: toISOString(1400) }), - ]); - state.addMessagesSorted( - [ - generateMsg({ id: '1', created_at: toISOString(100) }), - expectedFoundMsg, - generateMsg({ id: '3', created_at: toISOString(300) }), - generateMsg({ id: '4', created_at: toISOString(400) }), - ], - false, - false, - true, - 'new', - ); - state.addMessagesSorted( - [ - generateMsg({ id: '6', created_at: toISOString(600) }), - generateMsg({ id: '7', created_at: toISOString(700) }), - ], - false, - false, - true, - 'new', - ); - - const foundMessage = state.findMessageByTimestamp( - new Date(expectedFoundMsg.created_at).getTime(), - ); - expect(foundMessage.id).toBe(expectedFoundMsg.id); - }); - - it('finds the first message if multiple messages with the same timestamp', () => { - const expectedFoundMessage = generateMsg({ - id: '2', - created_at: toISOString(200), - }); - const msgWithSameTimestamp = { ...expectedFoundMessage, id: '3' }; - state.addMessagesSorted([ - generateMsg({ id: '12', created_at: toISOString(1200) }), - generateMsg({ id: '13', created_at: toISOString(1300) }), - generateMsg({ id: '14', created_at: toISOString(1400) }), - ]); - state.addMessagesSorted( - [ - generateMsg({ id: '1', created_at: toISOString(100) }), - expectedFoundMessage, - msgWithSameTimestamp, - generateMsg({ id: '3.5', created_at: toISOString(300) }), - generateMsg({ id: '4', created_at: toISOString(400) }), - ], - false, - false, - true, - 'new', - ); - state.addMessagesSorted( - [ - generateMsg({ id: '6', created_at: toISOString(600) }), - generateMsg({ id: '7', created_at: toISOString(700) }), - ], - false, - false, - true, - 'new', - ); - - const foundMessage = state.findMessageByTimestamp( - new Date(msgWithSameTimestamp.created_at).getTime(), - ); - expect(foundMessage.id).toBe(expectedFoundMessage.id); - }); - - it('returns null if the message is not found', () => { - state.addMessagesSorted([ - generateMsg({ id: '12', created_at: toISOString(1200) }), - generateMsg({ id: '13', created_at: toISOString(1300) }), - generateMsg({ id: '14', created_at: toISOString(1400) }), - ]); - const foundMessage = state.findMessageByTimestamp(200); - expect(foundMessage).toBeNull(); - }); -}); diff --git a/test/unit/messageDelivery/MessageDeliveryReporter.test.ts b/test/unit/messageDelivery/MessageDeliveryReporter.test.ts index 83f501b78..94c951761 100644 --- a/test/unit/messageDelivery/MessageDeliveryReporter.test.ts +++ b/test/unit/messageDelivery/MessageDeliveryReporter.test.ts @@ -23,6 +23,20 @@ const otherUser = { const mkMsg = (id: string, at: string | number | Date) => ({ id, created_at: new Date(at) }) as any; +// The delivery reporter now derives the latest message from `channel.messagePaginator.latestItems`, +// so tests seed the paginator's latest (head) window instead of assigning `channel.state.latestMessages`. +const setLatest = (channel: Channel, msgs: ReturnType[]) => { + channel.messagePaginator.clearStateAndCache(); + if (msgs.length) { + channel.messagePaginator.ingestPage({ + page: msgs, + isHead: true, + isTail: true, + setActive: true, + }); + } +}; + describe('MessageDeliveryReporter', () => { let client: StreamChat; let channel: Channel; @@ -57,7 +71,7 @@ describe('MessageDeliveryReporter', () => { .mockResolvedValue({ ok: true } as any); // last_read < last message - channel.state.latestMessages = [mkMsg('m1', '2025-01-01T10:00:00Z')]; + setLatest(channel, [mkMsg('m1', '2025-01-01T10:00:00Z')]); (channel.state as any).read['me'] = { last_read: new Date('2025-01-01T09:00:00Z') }; client.syncDeliveredCandidates([channel]); @@ -86,7 +100,7 @@ describe('MessageDeliveryReporter', () => { const channels = Array.from({ length: 110 }, (_, i) => { const channel = client.channel(channelType, i.toString()); channel.initialized = true; - channel.state.latestMessages = [mkMsg('m1', '2025-01-01T10:00:00Z')]; + setLatest(channel, [mkMsg('m1', '2025-01-01T10:00:00Z')]); (channel.state as any).read['me'] = { last_read: new Date('2025-01-01T09:00:00Z') }; return channel; }); @@ -130,7 +144,7 @@ describe('MessageDeliveryReporter', () => { .spyOn(client, 'markChannelsDelivered') .mockResolvedValue({ ok: true } as any); - channel.state.latestMessages = [mkMsg('m1', '2025-01-01T10:00:00Z')]; + setLatest(channel, [mkMsg('m1', '2025-01-01T10:00:00Z')]); (channel.state as any).read['me'] = { last_read: new Date('2025-01-01T09:00:00Z') }; client.syncDeliveredCandidates([channel]); @@ -151,7 +165,7 @@ describe('MessageDeliveryReporter', () => { .spyOn(client, 'markChannelsDelivered') .mockResolvedValue({ ok: true } as any); - channel.state.latestMessages = [mkMsg('m1', '2025-01-01T10:00:00Z')]; + setLatest(channel, [mkMsg('m1', '2025-01-01T10:00:00Z')]); (channel.state as any).read['me'] = { last_read: new Date('2025-01-01T09:00:00Z') }; client.syncDeliveredCandidates([channel]); @@ -165,7 +179,7 @@ describe('MessageDeliveryReporter', () => { .spyOn(client, 'markChannelsDelivered') .mockResolvedValue({ ok: true } as any); - (channel.state as any).latestMessages = [mkMsg('m1', '2025-01-01T10:00:00Z')]; + setLatest(channel, [mkMsg('m1', '2025-01-01T10:00:00Z')]); (channel.state as any).read['me'] = { last_read: new Date('2025-01-01T09:00:00Z'), last_delivered_at: new Date('2025-01-01T11:00:00Z'), @@ -182,7 +196,7 @@ describe('MessageDeliveryReporter', () => { .spyOn(client, 'markChannelsDelivered') .mockResolvedValue({} as any); - channel.state.latestMessages = [mkMsg('m1', 1000)]; + setLatest(channel, [mkMsg('m1', 1000)]); (channel.state as any).read['me'] = { last_read: new Date(0) }; client.syncDeliveredCandidates([channel]); @@ -201,12 +215,15 @@ describe('MessageDeliveryReporter', () => { .mockResolvedValue({} as any); (channel.state as any).read['me'] = { last_read: new Date('2025-01-01T09:00:00Z') }; - channel.state.latestMessages = [mkMsg('m1', '2025-01-01T10:00:00Z')]; + setLatest(channel, [mkMsg('m1', '2025-01-01T10:00:00Z')]); client.syncDeliveredCandidates([channel]); // newer message arrives before throttle fires - channel.state.latestMessages.push(mkMsg('m2', '2025-01-01T10:05:00Z')); + setLatest(channel, [ + mkMsg('m1', '2025-01-01T10:00:00Z'), + mkMsg('m2', '2025-01-01T10:05:00Z'), + ]); client.syncDeliveredCandidates([channel]); vi.advanceTimersByTime(1000); @@ -234,7 +251,7 @@ describe('MessageDeliveryReporter', () => { const ch1 = client.channel('messaging', 'ch1'); ch1.initialized = true; (ch1.state as any).read['me'] = { last_read: new Date(0) }; - (ch1.state as any).latestMessages = [mkMsg('m1', 1000)]; + setLatest(ch1, [mkMsg('m1', 1000)]); const ch2 = client.channel('messaging', 'ch2'); ch2.initialized = true; @@ -269,7 +286,7 @@ describe('MessageDeliveryReporter', () => { // While request is in-flight, a new candidate (different channel) arrives. (ch2.state as any).read['me'] = { last_read: new Date(0) }; - (ch2.state as any).latestMessages = [mkMsg('n1', 2000)]; + setLatest(ch2, [mkMsg('n1', 2000)]); client.syncDeliveredCandidates([ch2]); // Trying to announce during in-flight should be a no-op for sending @@ -314,7 +331,7 @@ describe('MessageDeliveryReporter', () => { vi.spyOn(channel, 'markAsReadRequest').mockResolvedValue({} as any); (channel.state as any).read['me'] = { last_read: new Date(0) }; - channel.state.latestMessages = [mkMsg('m1', 1000)]; + setLatest(channel, [mkMsg('m1', 1000)]); client.syncDeliveredCandidates([channel]); @@ -329,7 +346,7 @@ describe('MessageDeliveryReporter', () => { const channels = Array.from({ length: count }, (_, i) => { const channel = client.channel(channelType, (i + startId).toString()); channel.initialized = true; - channel.state.latestMessages = [mkMsg('m1', '2025-01-01T10:00:00Z')]; + setLatest(channel, [mkMsg('m1', '2025-01-01T10:00:00Z')]); (channel.state as any).read['me'] = { last_read: new Date('2025-01-01T09:00:00Z') }; return channel; }); @@ -513,7 +530,7 @@ describe('MessageDeliveryReporter', () => { vi.spyOn(channel, 'markAsReadRequest').mockRejectedValue({} as any); (channel.state as any).read['me'] = { last_read: new Date(0) }; - channel.state.latestMessages = [mkMsg('m1', 1000)]; + setLatest(channel, [mkMsg('m1', 1000)]); client.syncDeliveredCandidates([channel]); @@ -538,14 +555,15 @@ describe('MessageDeliveryReporter', () => { .mockResolvedValue({} as any); (channel.state as any).read['me'] = { last_read: new Date(0) }; - channel.state.latestMessages = []; + setLatest(channel, []); // simulate incoming message.new event const ev: Event = { type: 'message.new', created_at: new Date('2025-01-01T10:00:00Z').toISOString(), user: otherUser, - message: mkMsg('m1', '2025-01-01T10:00:00Z') as any, + // cid must match the paginator filter so message.new ingests into an interval + message: { ...mkMsg('m1', '2025-01-01T10:00:00Z'), cid: channel.cid } as any, }; channel._handleChannelEvent(ev); @@ -569,7 +587,7 @@ describe('MessageDeliveryReporter', () => { .mockResolvedValue({} as any); (channel.state as any).read['me'] = { last_read: new Date(0) }; - channel.state.latestMessages = []; + setLatest(channel, []); // simulate incoming message.new event const ev: Event = { @@ -592,7 +610,7 @@ describe('MessageDeliveryReporter', () => { .mockResolvedValue({} as any); (channel.state as any).read['me'] = { last_read: new Date(0) }; - channel.state.latestMessages = [mkMsg('m1', '2025-01-01T10:00:00Z') as any]; + setLatest(channel, [mkMsg('m1', '2025-01-01T10:00:00Z')]); client.syncDeliveredCandidates([channel]); @@ -617,7 +635,7 @@ describe('MessageDeliveryReporter', () => { .mockResolvedValue({} as any); (channel.state as any).read['me'] = { last_read: new Date(0) }; - channel.state.latestMessages = [mkMsg('m1', '2025-01-01T10:00:00Z') as any]; + setLatest(channel, [mkMsg('m1', '2025-01-01T10:00:00Z')]); client.syncDeliveredCandidates([channel]); diff --git a/test/unit/messageDelivery/MessageReceiptsTracker.test.ts b/test/unit/messageDelivery/MessageReceiptsTracker.test.ts index a946bdc27..95a66cfad 100644 --- a/test/unit/messageDelivery/MessageReceiptsTracker.test.ts +++ b/test/unit/messageDelivery/MessageReceiptsTracker.test.ts @@ -40,9 +40,13 @@ const createChannelMock = ({ return { channel: { state: { - findMessageByTimestamp, readStore, }, + // The default receipts locator now resolves timestamps via the message paginator; this mock + // fn (still named findMessageByTimestamp in tests) backs messagePaginator.findItemByTimestamp. + messagePaginator: { + findItemByTimestamp: findMessageByTimestamp, + }, } as unknown as Channel, readStore, }; diff --git a/test/unit/pagination/paginators/MessagePaginator.test.ts b/test/unit/pagination/paginators/MessagePaginator.test.ts index 73b32e8e8..64c6745bf 100644 --- a/test/unit/pagination/paginators/MessagePaginator.test.ts +++ b/test/unit/pagination/paginators/MessagePaginator.test.ts @@ -1071,6 +1071,137 @@ describe('MessagePaginator', () => { }); }); + describe('reflectUserUpdate()', () => { + it('patches the user on cached messages authored by the user and re-emits the active window', () => { + const paginator = new MessagePaginator({ channel, itemIndex }); + const byA1 = createMessage({ + id: 'a1', + user: { id: 'A' }, + created_at: '2021-01-01T00:00:00.000Z', + }); + const byB = createMessage({ + id: 'b1', + user: { id: 'B' }, + created_at: '2021-01-02T00:00:00.000Z', + }); + const byA2 = createMessage({ + id: 'a2', + user: { id: 'A' }, + created_at: '2021-01-03T00:00:00.000Z', + }); + + paginator.setItems({ + valueOrFactory: [byA1, byB, byA2], + isFirstPage: true, + isLastPage: true, + }); + + paginator.reflectUserUpdate({ id: 'A', name: 'Renamed A' }); + + expect(paginator.getItem('a1')?.user?.name).toBe('Renamed A'); + expect(paginator.getItem('a2')?.user?.name).toBe('Renamed A'); + expect(paginator.getItem('b1')?.user?.name).not.toBe('Renamed A'); + // the active window is re-emitted with the updated user object + expect(paginator.items?.find((m) => m.id === 'a1')?.user?.name).toBe('Renamed A'); + }); + }); + + describe('reflectReaction()', () => { + const currentUserId = 'me'; + const reaction = (type: string, userId: string) => ({ + created_at: '2021-01-01T00:00:00.000Z', + message_id: 'r1', + type, + user_id: userId, + }); + + beforeEach(() => { + (channel as unknown as { getClient: () => unknown }).getClient = () => ({ + userID: currentUserId, + }); + }); + + const seed = ( + paginator: MessagePaginator, + ownReactions: ReturnType[], + ) => { + paginator.setItems({ + valueOrFactory: [ + createMessage({ + created_at: '2021-01-01T00:00:00.000Z', + id: 'r1', + latest_reactions: ownReactions, + own_reactions: ownReactions, + }), + ], + isFirstPage: true, + isLastPage: true, + }); + }; + + it("preserves the current user's own_reactions when another user reacts", () => { + const paginator = new MessagePaginator({ channel, itemIndex }); + seed(paginator, [reaction('love', currentUserId)]); + + paginator.reflectReaction({ + message: createMessage({ + id: 'r1', + // server event omits our own_reactions and carries the merged groups + own_reactions: [], + reaction_groups: { + like: { count: 1, sum_scores: 1 } as never, + love: { count: 1, sum_scores: 1 } as never, + }, + }), + reaction: reaction('like', 'other'), + }); + + const updated = paginator.getItem('r1'); + expect(updated?.own_reactions?.map((r) => r.type)).toEqual(['love']); + // the event's server-computed reaction_groups are applied as-is + expect(updated?.reaction_groups?.like).toBeDefined(); + }); + + it("adds the current user's reaction to own_reactions", () => { + const paginator = new MessagePaginator({ channel, itemIndex }); + seed(paginator, []); + + paginator.reflectReaction({ + message: createMessage({ id: 'r1' }), + reaction: reaction('love', currentUserId), + }); + + expect(paginator.getItem('r1')?.own_reactions?.map((r) => r.type)).toEqual([ + 'love', + ]); + }); + + it('removes the reaction from own_reactions on reaction.deleted', () => { + const paginator = new MessagePaginator({ channel, itemIndex }); + seed(paginator, [reaction('love', currentUserId)]); + + paginator.reflectReaction({ + message: createMessage({ id: 'r1', own_reactions: [] }), + reaction: reaction('love', currentUserId), + removed: true, + }); + + expect(paginator.getItem('r1')?.own_reactions ?? []).toEqual([]); + }); + + it('does not add another user reaction to own_reactions', () => { + const paginator = new MessagePaginator({ channel, itemIndex }); + seed(paginator, []); + + paginator.reflectReaction({ + message: createMessage({ id: 'r1' }), + reaction: reaction('love', 'other'), + }); + + expect(paginator.getItem('r1')?.own_reactions ?? []).toEqual([]); + }); + }); + describe.todo('postQueryReconcile and deriveCursor for', () => {}); describe('linear pagination', () => { describe('updates the hasMoreTail flag only if the first message on page is the first message in interval', () => { @@ -1236,6 +1367,307 @@ describe('MessagePaginator', () => { }); }); + describe('seedFirstPageSync()', () => { + const msg = (id: string, day: string) => + createMessage({ + cid: 'channel-id', + id, + created_at: `2020-01-${day}T00:00:00.000Z`, + }); + + it('seeds a latest page as the head window (nothing newer to load)', () => { + // First-page reconcile reads the client for the unread snapshot; no user => snapshot skipped. + (channel as unknown as { getClient: () => unknown }).getClient = () => ({ + user: undefined, + }); + const paginator = new MessagePaginator({ channel, itemIndex }); + // Fewer messages than the requested page size => dataset edges reached both ways. + paginator.seedFirstPageSync([msg('m8', '08'), msg('m9', '09')], 100); + + expect(paginator.latestItem?.id).toBe('m9'); + expect(paginator.hasMoreHead).toBe(false); + expect(paginator.hasMoreTail).toBe(false); + }); + + it('seeds an around/jump open as a middle window, not the head', () => { + (channel as unknown as { getClient: () => unknown }).getClient = () => ({ + user: undefined, + }); + const paginator = new MessagePaginator({ channel, itemIndex }); + // A full page centered on m6: messages exist on both sides beyond this window, so the + // paginator must NOT flag it as the latest (head) page — regression for a channel opened + // via `messages: { id_around }` rather than the latest page. + paginator.seedFirstPageSync( + [ + msg('m4', '04'), + msg('m5', '05'), + msg('m6', '06'), + msg('m7', '07'), + msg('m8', '08'), + ], + 5, + { id_around: 'm6' }, + ); + + expect(paginator.hasMoreHead).toBe(true); + expect(paginator.hasMoreTail).toBe(true); + }); + }); + + describe('latest window, truncation & isUpToDate parity', () => { + const msg = (id: string, day: string) => + createMessage({ + cid: 'channel-id', + id, + created_at: `2020-01-${day}T00:00:00.000Z`, + }); + + describe('latestItems / latestItem', () => { + it('reflect the active head window', () => { + const paginator = new MessagePaginator({ channel, itemIndex }); + paginator.ingestPage({ + page: [msg('m8', '08'), msg('m9', '09')], + isHead: true, + isTail: true, + setActive: true, + }); + + expect(paginator.items?.map((m) => m.id)).toEqual(['m8', 'm9']); + expect(paginator.latestItems.map((m) => m.id)).toEqual(['m8', 'm9']); + expect(paginator.latestItem?.id).toBe('m9'); + }); + + it('reflect the head window even while an older window is active (after a jump)', () => { + const paginator = new MessagePaginator({ channel, itemIndex }); + paginator.ingestPage({ + page: [msg('m8', '08'), msg('m9', '09')], + isHead: true, + setActive: false, + }); + // an older, disjoint window is now the active one + paginator.ingestPage({ + page: [msg('m4', '04'), msg('m5', '05')], + setActive: true, + }); + + expect(paginator.items?.map((m) => m.id)).toEqual(['m4', 'm5']); // active window + expect(paginator.latestItems.map((m) => m.id)).toEqual(['m8', 'm9']); // newest window + expect(paginator.latestItem?.id).toBe('m9'); + }); + + it('return the newest loaded window even when it is not flagged isHead (query/hydration seed)', () => { + // The query/hydration seed does not reliably mark a latest page as isHead, so latestItems + // uses the head-most *loaded* window rather than requiring the flag. + const paginator = new MessagePaginator({ channel, itemIndex }); + paginator.ingestPage({ + page: [msg('m4', '04'), msg('m5', '05')], + setActive: true, + }); + + expect(paginator.latestItems.map((m) => m.id)).toEqual(['m4', 'm5']); + expect(paginator.latestItem?.id).toBe('m5'); + }); + + it('are empty when nothing is loaded', () => { + const paginator = new MessagePaginator({ channel, itemIndex }); + expect(paginator.latestItems).toEqual([]); + expect(paginator.latestItem).toBeUndefined(); + }); + }); + + describe('truncate()', () => { + it('drops messages strictly older than truncated_at and keeps the rest', () => { + const paginator = new MessagePaginator({ channel, itemIndex }); + paginator.ingestPage({ + page: [msg('m1', '01'), msg('m5', '05'), msg('m9', '09')], + isHead: true, + isTail: true, + setActive: true, + }); + + paginator.truncate({ truncatedAt: new Date('2020-01-05T00:00:00.000Z') }); + + // m1 dropped; m5 kept (equal, not strictly older); m9 kept + expect(paginator.items?.map((m) => m.id)).toEqual(['m5', 'm9']); + expect(paginator.getItem('m1')).toBeUndefined(); + expect(paginator.getItem('m5')).toBeTruthy(); + }); + + it('marks the interval that spanned the cutoff as the new tail', () => { + const paginator = new MessagePaginator({ channel, itemIndex }); + // isHead but NOT isTail → hasMoreTail starts true + paginator.ingestPage({ + page: [msg('m1', '01'), msg('m5', '05'), msg('m9', '09')], + isHead: true, + setActive: true, + }); + expect(paginator.hasMoreTail).toBe(true); + + paginator.truncate({ truncatedAt: new Date('2020-01-05T00:00:00.000Z') }); + + expect(paginator.items?.map((m) => m.id)).toEqual(['m5', 'm9']); + // it lost its oldest member → nothing older remains → it is now the tail + expect(paginator.hasMoreTail).toBe(false); + }); + + it('leaves an interval that did not span the cutoff untouched (keeps hasMoreTail)', () => { + const paginator = new MessagePaginator({ channel, itemIndex }); + // active newer window, not a tail (older messages may still be unloaded) + paginator.ingestPage({ + page: [msg('m8', '08'), msg('m9', '09')], + isHead: true, + setActive: true, + }); + // a separate, older, disjoint window + paginator.ingestPage({ page: [msg('m2', '02'), msg('m3', '03')] }); + + paginator.truncate({ truncatedAt: new Date('2020-01-05T00:00:00.000Z') }); + + // the older window was entirely older than the cutoff → dropped + expect(paginator.getItem('m2')).toBeUndefined(); + expect(paginator.getItem('m3')).toBeUndefined(); + // the active (newer) window did not span the cutoff → unchanged, still expects older pages + expect(paginator.items?.map((m) => m.id)).toEqual(['m8', 'm9']); + expect(paginator.hasMoreTail).toBe(true); + }); + + it('re-emits the active window only once (batched)', () => { + const paginator = new MessagePaginator({ channel, itemIndex }); + paginator.ingestPage({ + page: [msg('m1', '01'), msg('m2', '02'), msg('m3', '03'), msg('m9', '09')], + isHead: true, + isTail: true, + setActive: true, + }); + + const partialNextSpy = vi.spyOn(paginator.state, 'partialNext'); + paginator.truncate({ truncatedAt: new Date('2020-01-05T00:00:00.000Z') }); + + // three messages removed, but a single state emission + expect(paginator.items?.map((m) => m.id)).toEqual(['m9']); + expect(partialNextSpy).toHaveBeenCalledTimes(1); + }); + + it('activates the surviving window instead of blanking when the active window is truncated away', () => { + const paginator = new MessagePaginator({ channel, itemIndex }); + // a surviving newer window + paginator.ingestPage({ + page: [msg('m8', '08'), msg('m9', '09')], + isHead: true, + setActive: false, + }); + // the active window is an older, disjoint one + paginator.ingestPage({ + page: [msg('m1', '01'), msg('m2', '02')], + setActive: true, + }); + expect(paginator.items?.map((m) => m.id)).toEqual(['m1', 'm2']); + + paginator.truncate({ truncatedAt: new Date('2020-01-05T00:00:00.000Z') }); + + // active window removed entirely, but we show the surviving window — NOT an empty list + expect(paginator.getItem('m1')).toBeUndefined(); + expect(paginator.items?.map((m) => m.id)).toEqual(['m8', 'm9']); + }); + + it('falls back to the nearest (tail-most) surviving window when several survive', () => { + const paginator = new MessagePaginator({ channel, itemIndex }); + paginator.ingestPage({ + page: [msg('m8', '08'), msg('m9', '09')], + isHead: true, + setActive: false, + }); + paginator.ingestPage({ + page: [msg('m5', '05'), msg('m6', '06')], + setActive: false, + }); + // active is the oldest window + paginator.ingestPage({ + page: [msg('m1', '01'), msg('m2', '02')], + setActive: true, + }); + + paginator.truncate({ truncatedAt: new Date('2020-01-04T00:00:00.000Z') }); + + // active [m1,m2] removed; nearest survivor to where it was = the oldest survivor [m5,m6] + expect(paginator.items?.map((m) => m.id)).toEqual(['m5', 'm6']); + }); + + it('splits at the correct point with duplicate timestamps at the boundary', () => { + const paginator = new MessagePaginator({ channel, itemIndex }); + paginator.ingestPage({ + page: [ + msg('m1', '01'), + msg('m3a', '03'), + msg('m3b', '03'), + msg('m5', '05'), + msg('m7', '07'), + ], + isHead: true, + isTail: true, + setActive: true, + }); + + // cutoff 04: everything strictly older (m1, both m3*) dropped; m5, m7 kept + paginator.truncate({ truncatedAt: new Date('2020-01-04T00:00:00.000Z') }); + + expect(paginator.items?.map((m) => m.id)).toEqual(['m5', 'm7']); + expect(paginator.getItem('m3b')).toBeUndefined(); + }); + + it('is a no-op for an invalid cutoff date', () => { + const paginator = new MessagePaginator({ channel, itemIndex }); + paginator.ingestPage({ + page: [msg('m1', '01')], + isHead: true, + isTail: true, + setActive: true, + }); + const partialNextSpy = vi.spyOn(paginator.state, 'partialNext'); + + paginator.truncate({ truncatedAt: new Date('not-a-date') }); + + expect(paginator.items?.map((m) => m.id)).toEqual(['m1']); + expect(partialNextSpy).not.toHaveBeenCalled(); + }); + }); + + describe('isUpToDate parity — message.new routing', () => { + it('appends a newer message when the head window is active', () => { + const paginator = new MessagePaginator({ channel, itemIndex }); + paginator.ingestPage({ + page: [msg('m1', '01'), msg('m2', '02')], + isHead: true, + isTail: true, + setActive: true, + }); + + paginator.ingestItem(msg('m3', '03')); + + expect(paginator.items?.map((m) => m.id)).toEqual(['m1', 'm2', 'm3']); + expect(paginator.latestItem?.id).toBe('m3'); + }); + + it('does not inject a newer message into an older active window (isUpToDate=false analog)', () => { + const paginator = new MessagePaginator({ channel, itemIndex }); + paginator.ingestPage({ + page: [msg('m8', '08'), msg('m9', '09')], + isHead: true, + setActive: false, + }); + paginator.ingestPage({ + page: [msg('m4', '04'), msg('m5', '05')], + setActive: true, + }); + + paginator.ingestItem(msg('m10', '10')); + + // the viewed (older) window is unchanged — the new message is not pushed onto it + expect(paginator.items?.map((m) => m.id)).toEqual(['m4', 'm5']); + }); + }); + }); + describe('mergeNewestPage()', () => { const m = (id: string, day: string, overrides: Partial = {}) => createMessage({ diff --git a/test/unit/pagination/utility.search.test.ts b/test/unit/pagination/utility.search.test.ts new file mode 100644 index 000000000..c2d53b649 --- /dev/null +++ b/test/unit/pagination/utility.search.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { lowerBound } from '../../../src/pagination/utility.search'; + +describe('lowerBound', () => { + // predicate = "value at index is >= threshold" over a sorted array + const firstAtLeast = (sorted: number[], threshold: number) => + lowerBound(sorted.length, (index) => sorted[index] >= threshold); + + it('returns 0 when the whole range satisfies the predicate', () => { + expect(firstAtLeast([5, 6, 7], 5)).toBe(0); + }); + + it('returns length when no index satisfies the predicate', () => { + expect(firstAtLeast([1, 2, 3], 10)).toBe(3); + }); + + it('returns 0 for an empty range', () => { + expect(lowerBound(0, () => true)).toBe(0); + }); + + it('finds the boundary in the middle', () => { + expect(firstAtLeast([1, 3, 5, 7, 9], 5)).toBe(2); + expect(firstAtLeast([1, 3, 5, 7, 9], 6)).toBe(3); + }); + + it('returns the first satisfying index across a plateau of equal values', () => { + expect(firstAtLeast([1, 5, 5, 5, 9], 5)).toBe(1); + }); +}); From caba066bfcd28a55ddcbba0b6e3ca9734d2f4da0 Mon Sep 17 00:00:00 2001 From: martincupela Date: Mon, 20 Jul 2026 13:57:38 +0200 Subject: [PATCH 02/25] refactor(channel_state): remove main message-list storage wip --- src/channel.ts | 64 ++------ src/channel_state.ts | 381 ++++--------------------------------------- src/client.ts | 30 +--- src/utils.ts | 231 -------------------------- 4 files changed, 49 insertions(+), 657 deletions(-) diff --git a/src/channel.ts b/src/channel.ts index d99c5c4da..4a1b9bc2e 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -11,7 +11,6 @@ import { formatMessage, generateChannelTempCid, logChatPromiseExecution, - messageSetPagination, normalizeQuerySort, } from './utils'; import type { StreamChat } from './client'; @@ -1830,25 +1829,13 @@ export class Channel { ); } - // add any messages to our channel state - const { messageSet, filteredMessageIds } = this._initializeState( - state, - messageSetToAddToIfDoesNotExist, - ); - messageSet.pagination = { - ...messageSet.pagination, - ...messageSetPagination({ - parentSet: messageSet, - messagePaginationOptions: options?.messages, - requestedPageSize: - options?.messages?.limit ?? DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE, - returnedPage: state.messages, - filteredReturnedPage: state.messages.filter( - (m) => !filteredMessageIds.includes(m.id), - ), - logger: this.getClient().logger, - }), - }; + // Seed read/members/pinned/thread-cleanup state; the message list is in the paginator. + this._initializeState(state); + // The queried page is the latest set unless this was a jump/around query. + const isLatestMessageSet = + messageSetToAddToIfDoesNotExist === 'latest' && + !options?.messages?.id_around && + !(options?.messages as MessagePaginationOptions | undefined)?.created_at_around; this.getClient().polls.hydratePollCache(state.messages, true); this.getClient().reminders.hydrateState(state.messages); @@ -1882,14 +1869,14 @@ export class Channel { type: 'channels.queried', queriedChannels: { channels: [state], - isLatestMessageSet: messageSet.isLatest, + isLatestMessageSet, }, }); this.getClient().offlineDb?.executeQuerySafely( (db) => db.upsertChannels?.({ channels: [state], - isLatestMessagesSet: messageSet.isLatest, + isLatestMessagesSet: isLatestMessageSet, }), { method: 'upsertChannels' }, ); @@ -2527,13 +2514,6 @@ export class Channel { const truncatedAtDate = new Date(event.channel.truncated_at); const truncatedAt = +truncatedAtDate; - channelState.messageSets.forEach((messageSet, messageSetIndex) => { - messageSet.messages.forEach(({ created_at: createdAt, id }) => { - if (truncatedAt > +createdAt) - channelState.removeMessage({ id, messageSetIndex }); - }); - }); - channelState.pinnedMessages.forEach(({ id, created_at: createdAt }) => { if (truncatedAt > +createdAt) channelState.removePinnedMessage({ id } as MessageResponse); @@ -2710,6 +2690,7 @@ export class Channel { channel._syncStateFromChannelData(channel.data, previousChannelData); if (event.clear_history) { channelState.clearMessages(); + this.messagePaginator.clearStateAndCache(); } break; } @@ -2805,10 +2786,7 @@ export class Channel { this.state.syncMemberCountFromChannelData(data, fallbackData); } - _initializeState( - state: ChannelAPIResponse, - messageSetToAddToIfDoesNotExist: MessageSetType = 'latest', - ) { + _initializeState(state: ChannelAPIResponse) { const { state: clientState, user, userID } = this.getClient(); // add the members and users @@ -2824,17 +2802,10 @@ export class Channel { this.state.membership = state.membership || {}; - const messages = state.messages || []; - if (!this.state.messages) { - this.state.initMessages(); - } - const { messageSet, filteredMessageIds } = this.state.addMessagesSorted( - messages, - false, - true, - true, - messageSetToAddToIfDoesNotExist, - ); + // The main message list is seeded into channel.messagePaginator (see Channel.query / + // client.hydrateActiveChannels). This maintains thread-reply state, performs stale-thread + // cleanup, and advances last_message_at for the initializing page. + this.state.addMessagesSorted(state.messages || [], false, true, true); if (!this.state.pinnedMessages) { this.state.pinnedMessages = []; @@ -2909,11 +2880,6 @@ export class Channel { { changedUserIds: entries.map(([userId]) => userId) }, ); } - - return { - messageSet, - filteredMessageIds, - }; } _extendEventWithOwnReactions(event: Event) { diff --git a/src/channel_state.ts b/src/channel_state.ts index 3b656e183..48b07a504 100644 --- a/src/channel_state.ts +++ b/src/channel_state.ts @@ -5,8 +5,6 @@ import type { LocalMessage, MessageResponse, MessageResponseBase, - MessageSet, - MessageSetType, PendingMessageResponse, ReactionResponse, UserResponse, @@ -16,7 +14,6 @@ import { addToMessageList, formatMessage, } from './utils'; -import { DEFAULT_MESSAGE_SET_PAGINATION } from './constants'; import { StateStore } from './store'; type ChannelReadStatus = Record< @@ -58,38 +55,6 @@ export type OwnCapabilitiesState = { ownCapabilities: string[]; }; -const messageSetBounds = ( - a: LocalMessage[] | MessageResponse[], - b: LocalMessage[] | MessageResponse[], -) => ({ - newestMessageA: new Date(a[0]?.created_at ?? 0), - oldestMessageA: new Date(a.slice(-1)[0]?.created_at ?? 0), - newestMessageB: new Date(b[0]?.created_at ?? 0), - oldestMessageB: new Date(b.slice(-1)[0]?.created_at ?? 0), -}); - -const aContainsOrEqualsB = (a: LocalMessage[], b: LocalMessage[]) => { - const { newestMessageA, newestMessageB, oldestMessageA, oldestMessageB } = - messageSetBounds(a, b); - return newestMessageA >= newestMessageB && oldestMessageB >= oldestMessageA; -}; - -const aOverlapsB = (a: LocalMessage[], b: LocalMessage[]) => { - const { newestMessageA, newestMessageB, oldestMessageA, oldestMessageB } = - messageSetBounds(a, b); - return ( - oldestMessageA < oldestMessageB && - oldestMessageB < newestMessageA && - newestMessageA < newestMessageB - ); -}; - -const messageSetsOverlapByTimestamp = (a: LocalMessage[], b: LocalMessage[]) => - aContainsOrEqualsB(a, b) || - aContainsOrEqualsB(b, a) || - aOverlapsB(a, b) || - aOverlapsB(b, a); - /** * ChannelState - A container class for the channel state. */ @@ -115,13 +80,6 @@ export class ChannelState { * be pushed on to message list. */ isUpToDate: boolean; - /** - * Disjoint lists of messages - * Users can jump in the message list (with searching) and this can result in disjoint lists of messages - * The state manages these lists and merges them when lists overlap - * The messages array contains the currently active set - */ - messageSets: MessageSet[] = []; constructor(channel: Channel) { this._channel = channel; @@ -141,7 +99,6 @@ export class ChannelState { }); this.syncMemberCountFromChannelData(channel?.data); this.syncOwnCapabilitiesFromChannelData(channel?.data); - this.initMessages(); this.pinnedMessages = []; this.pending_messages = []; this.threads = {}; @@ -160,28 +117,6 @@ export class ChannelState { : null; } - get messages() { - return this.messageSets.find((s) => s.isCurrent)?.messages || []; - } - - set messages(messages: Array>) { - const index = this.messageSets.findIndex((s) => s.isCurrent); - this.messageSets[index].messages = messages; - } - - /** - * The list of latest messages - * The messages array not always contains the latest messages (for example if a user searched for an earlier message, that is in a different message set) - */ - get latestMessages() { - return this.messageSets.find((s) => s.isLatest)?.messages || []; - } - - set latestMessages(messages: Array>) { - const index = this.messageSets.findIndex((s) => s.isLatest); - this.messageSets[index].messages = messages; - } - get members() { return this.membersStore.getLatestValue().members; } @@ -326,42 +261,26 @@ export class ChannelState { this.watcherStore.partialNext({ watcherCount }); } - get messagePagination() { - return ( - this.messageSets.find((s) => s.isCurrent)?.pagination || - DEFAULT_MESSAGE_SET_PAGINATION - ); - } - - pruneOldest(maxMessages: number) { - const currentIndex = this.messageSets.findIndex((s) => s.isCurrent); - if (this.messageSets[currentIndex].isLatest) { - const newMessages = this.messageSets[currentIndex].messages; - this.messageSets[currentIndex].messages = newMessages.slice(-maxMessages); - this.messageSets[currentIndex].pagination.hasPrev = true; - } - } - /** - * addMessageSorted - Add a message to the state + * addMessageSorted - Maintain thread-reply state for a single message. + * + * The main channel message list lives in `channel.messagePaginator` now; this only appends thread + * replies to `state.threads` and advances `last_message_at`. * * @param {MessageResponse} newMessage A new message * @param {boolean} timestampChanged Whether updating a message with changed created_at value. * @param {boolean} addIfDoesNotExist Add message if it is not in the list, used to prevent out of order updated messages from being added. - * @param {MessageSetType} messageSetToAddToIfDoesNotExist Which message set to add to if message is not in the list (only used if addIfDoesNotExist is true) */ addMessageSorted( newMessage: MessageResponse | LocalMessage, timestampChanged = false, addIfDoesNotExist = true, - messageSetToAddToIfDoesNotExist: MessageSetType = 'latest', ) { return this.addMessagesSorted( [newMessage], timestampChanged, false, addIfDoesNotExist, - messageSetToAddToIfDoesNotExist, ); } @@ -381,39 +300,24 @@ export class ChannelState { * @param {boolean} timestampChanged Whether updating messages with changed created_at value. * @param {boolean} initializing Whether channel is being initialized. * @param {boolean} addIfDoesNotExist Add message if it is not in the list, used to prevent out of order updated messages from being added. - * @param {MessageSetType} messageSetToAddToIfDoesNotExist Which message set to add to if messages are not in the list (only used if addIfDoesNotExist is true) - * */ addMessagesSorted( newMessages: (MessageResponse | LocalMessage)[], timestampChanged = false, initializing = false, addIfDoesNotExist = true, - messageSetToAddToIfDoesNotExist: MessageSetType = 'current', ) { - const { messagesToAdd, targetMessageSetIndex } = this.findTargetMessageSet( - newMessages, - addIfDoesNotExist, - messageSetToAddToIfDoesNotExist, - ); - - const filteredMessageIds: string[] = []; - - for (let i = 0; i < messagesToAdd.length; i += 1) { - const isFromShadowBannedUser = messagesToAdd[i].shadowed; - if (isFromShadowBannedUser && addIfDoesNotExist) { - filteredMessageIds.push(messagesToAdd[i].id); + for (let i = 0; i < newMessages.length; i += 1) { + if (newMessages[i].shadowed && addIfDoesNotExist) { continue; } - // If message is already formatted we can skip the tasks below - // This will be true for messages that are already present at the state -> this happens when we perform merging of message sets - // This will be also true for message previews used by some SDKs - const isMessageFormatted = messagesToAdd[i].created_at instanceof Date; + // If message is already formatted we can skip the tasks below. + const isMessageFormatted = newMessages[i].created_at instanceof Date; let message: ReturnType; if (isMessageFormatted) { - message = messagesToAdd[i] as ReturnType; + message = newMessages[i] as ReturnType; } else { - message = this.formatMessage(messagesToAdd[i]); + message = this.formatMessage(newMessages[i]); if (message.user && this._channel?.cid) { /** @@ -452,29 +356,11 @@ export class ChannelState { } } - // update or append the messages... - const parentID = message.parent_id; - - // add to the given message set - if ((!parentID || message.show_in_channel) && targetMessageSetIndex !== -1) { - this.messageSets[targetMessageSetIndex].messages = this._addToMessageList( - this.messageSets[targetMessageSetIndex].messages, - message, - timestampChanged, - 'created_at', - addIfDoesNotExist, - ); - } - /** - * Add message to thread if applicable and the message - * was added when querying for replies, or the thread already exits. - * This is to prevent the thread state from getting out of sync if - * a thread message is shown in channel but older than the newest thread - * message. This situation can result in a thread state where a random - * message is "oldest" message, and newer messages are therefore not loaded. - * This can also occur if an old thread message is updated. + * Add message to thread if applicable and the message was added when querying for replies, + * or the thread already exists. The main channel message list is owned by the paginator. */ + const parentID = message.parent_id; if (parentID && !initializing) { const thread = this.threads[parentID] || []; this.threads[parentID] = this._addToMessageList( @@ -486,11 +372,6 @@ export class ChannelState { ); } } - - return { - messageSet: this.messageSets[targetMessageSetIndex], - filteredMessageIds, - }; } /** @@ -775,10 +656,9 @@ export class ChannelState { this.addMessagesSorted(updatedMessages, true); }; - if (!message.parent_id) { - this.messageSets.forEach((set) => update(set.messages)); - } else if (message.parent_id && this.threads[message.parent_id]) { - // prevent going through all the threads even though it is possible to quote a message from another thread + // Main-list quoted-reference updates are handled by messagePaginator.reflectQuotedMessageUpdate; + // here we only keep thread replies in sync. + if (message.parent_id && this.threads[message.parent_id]) { update(this.threads[message.parent_id]); } } @@ -803,7 +683,7 @@ export class ChannelState { msg: ReturnType, ) => ReturnType, ) { - const { parent_id, show_in_channel, pinned } = message; + const { parent_id, pinned } = message; if (parent_id && this.threads[parent_id]) { const thread = this.threads[parent_id]; @@ -814,19 +694,6 @@ export class ChannelState { } } - if ((!show_in_channel && !parent_id) || show_in_channel) { - const messageSetIndex = this.findMessageSetIndex(message); - if (messageSetIndex !== -1) { - const msgIndex = this.messageSets[messageSetIndex].messages.findIndex( - (msg) => msg.id === message.id, - ); - if (msgIndex !== -1) { - const upMsg = updateFunc(this.messageSets[messageSetIndex].messages[msgIndex]); - this.messageSets[messageSetIndex].messages[msgIndex] = upMsg; - } - } - } - if (pinned) { const msgIndex = this.pinnedMessages.findIndex((msg) => msg.id === message.id); if (msgIndex !== -1) { @@ -879,12 +746,9 @@ export class ChannelState { * * @return {boolean} Returns if the message was removed */ - removeMessage(messageToRemove: { - id: string; - messageSetIndex?: number; - parent_id?: string; - }) { - let isRemoved = false; + removeMessage(messageToRemove: { id: string; parent_id?: string }) { + // The main channel message list is owned by the paginator (use messagePaginator.removeItem); + // this only removes thread replies from state.threads. if (messageToRemove.parent_id && this.threads[messageToRemove.parent_id]) { const { removed, result: threadMessages } = this.removeMessageFromArray( this.threads[messageToRemove.parent_id], @@ -892,21 +756,10 @@ export class ChannelState { ); this.threads[messageToRemove.parent_id] = threadMessages; - isRemoved = removed; - } else { - const messageSetIndex = - messageToRemove.messageSetIndex ?? this.findMessageSetIndex(messageToRemove); - if (messageSetIndex !== -1) { - const { removed, result: messages } = this.removeMessageFromArray( - this.messageSets[messageSetIndex].messages, - messageToRemove, - ); - this.messageSets[messageSetIndex].messages = messages; - isRemoved = removed; - } + return removed; } - return isRemoved; + return false; } removeMessageFromArray = ( @@ -938,8 +791,7 @@ export class ChannelState { } }; - this.messageSets.forEach((set) => _updateUserMessages(set.messages, user)); - + // Main-list user references are updated on the paginator (messagePaginator.reflectUserUpdate). for (const parentId in this.threads) { _updateUserMessages(this.threads[parentId], user); } @@ -958,10 +810,7 @@ export class ChannelState { hardDelete = false, deletedAt?: LocalMessage['deleted_at'], ) => { - this.messageSets.forEach(({ messages }) => - _deleteUserMessages({ messages, user, hardDelete, deletedAt: deletedAt ?? null }), - ); - + // Main-list deletions are applied on the paginator (messagePaginator.applyMessageDeletionForUser). for (const parentId in this.threads) { _deleteUserMessages({ messages: this.threads[parentId], @@ -1001,22 +850,14 @@ export class ChannelState { } } + /** + * Clears the pinned-message cache. The main channel message list is owned by the paginator + * (clear it via `channel.messagePaginator.clearStateAndCache()`). + */ clearMessages() { - this.initMessages(); this.pinnedMessages = []; } - initMessages() { - this.messageSets = [ - { - messages: [], - isLatest: true, - isCurrent: true, - pagination: { ...DEFAULT_MESSAGE_SET_PAGINATION }, - }, - ]; - } - /** * findMessage - Finds a message inside the state * @@ -1034,169 +875,7 @@ export class ChannelState { return messages.find((m) => m.id === messageId); } - const messageSetIndex = this.findMessageSetIndex({ id: messageId }); - if (messageSetIndex === -1) { - return undefined; - } - return this.messageSets[messageSetIndex].messages.find((m) => m.id === messageId); - } - - private areMessageSetsOverlap( - messages1: Array<{ id: string }>, - messages2: Array<{ id: string }>, - ) { - return messages1.some((m1) => messages2.find((m2) => m1.id === m2.id)); - } - - private findMessageSetIndex(message: { id?: string }) { - return this.messageSets.findIndex( - (set) => !!set.messages.find((m) => m.id === message.id), - ); - } - - /** - * Identifies the set index into which a message set would pertain if its first item's creation date corresponded to oldestTimestampMs. - * @param oldestTimestampMs - */ - private findMessageSetByOldestTimestamp = (oldestTimestampMs: number): number => { - let lo = 0, - hi = this.messageSets.length; - while (lo < hi) { - const mid = (lo + hi) >>> 1; - const msgSet = this.messageSets[mid]; - // should not happen - if (msgSet.messages.length === 0) return -1; - - const oldestMessageTimestampInSet = msgSet.messages[0].created_at.getTime(); - if (oldestMessageTimestampInSet <= oldestTimestampMs) hi = mid; - else lo = mid + 1; - } - return lo; - }; - - private findTargetMessageSet( - newMessages: (MessageResponse | LocalMessage)[], - addIfDoesNotExist = true, - messageSetToAddToIfDoesNotExist: MessageSetType = 'current', - ) { - let messagesToAdd: (MessageResponse | LocalMessage)[] = newMessages; - let targetMessageSetIndex!: number; - if (newMessages.length === 0) - return { targetMessageSetIndex: 0, messagesToAdd: newMessages }; - if (addIfDoesNotExist) { - const overlappingMessageSetIndicesByMsgIds = this.messageSets - .map((_, i) => i) - .filter((i) => - this.areMessageSetsOverlap(this.messageSets[i].messages, newMessages), - ); - const overlappingMessageSetIndicesByTimestamp = this.messageSets - .map((_, i) => i) - .filter((i) => - messageSetsOverlapByTimestamp( - this.messageSets[i].messages, - newMessages.map(formatMessage), - ), - ); - switch (messageSetToAddToIfDoesNotExist) { - case 'new': - if (overlappingMessageSetIndicesByMsgIds.length > 0) { - targetMessageSetIndex = overlappingMessageSetIndicesByMsgIds[0]; - } else if (overlappingMessageSetIndicesByTimestamp.length > 0) { - targetMessageSetIndex = overlappingMessageSetIndicesByTimestamp[0]; - // No new message set is created if newMessages only contains thread replies - } else if (newMessages.some((m) => !m.parent_id)) { - // find the index to insert the set - const setIngestIndex = this.findMessageSetByOldestTimestamp( - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - new Date(newMessages[0].created_at!).getTime(), - ); - if (setIngestIndex === -1) { - this.messageSets.push({ - messages: [], - isCurrent: false, - isLatest: false, - pagination: { ...DEFAULT_MESSAGE_SET_PAGINATION }, - }); - targetMessageSetIndex = this.messageSets.length - 1; - } else { - const isLatest = setIngestIndex === 0; - this.messageSets.splice(setIngestIndex, 0, { - messages: [], - isCurrent: false, - isLatest, - pagination: { ...DEFAULT_MESSAGE_SET_PAGINATION }, // fixme: it is problematic decide about pagination without having data - }); - if (isLatest) { - this.messageSets.slice(1).forEach((set) => { - set.isLatest = false; - }); - } - targetMessageSetIndex = setIngestIndex; - } - } - break; - case 'current': - // determine if there is another set to which it would match taken into consideration the timestamp - if (overlappingMessageSetIndicesByTimestamp.length > 0) { - targetMessageSetIndex = overlappingMessageSetIndicesByTimestamp[0]; - } else { - targetMessageSetIndex = this.messageSets.findIndex((s) => s.isCurrent); - } - break; - case 'latest': - // determine if there is another set to which it would match taken into consideration the timestamp - if (overlappingMessageSetIndicesByTimestamp.length > 0) { - targetMessageSetIndex = overlappingMessageSetIndicesByTimestamp[0]; - } else { - targetMessageSetIndex = this.messageSets.findIndex((s) => s.isLatest); - } - break; - default: - targetMessageSetIndex = -1; - } - // when merging the target set will be the first one from the overlapping message sets - const mergeTargetMessageSetIndex = overlappingMessageSetIndicesByMsgIds.splice( - 0, - 1, - )[0]; - const mergeSourceMessageSetIndices = [...overlappingMessageSetIndicesByMsgIds]; - if ( - mergeTargetMessageSetIndex !== undefined && - mergeTargetMessageSetIndex !== targetMessageSetIndex - ) { - mergeSourceMessageSetIndices.push(targetMessageSetIndex); - } - // merge message sets - if (mergeSourceMessageSetIndices.length > 0) { - const target = this.messageSets[mergeTargetMessageSetIndex]; - const sources = this.messageSets.filter( - (_, i) => mergeSourceMessageSetIndices.indexOf(i) !== -1, - ); - sources.forEach((messageSet) => { - target.isLatest = target.isLatest || messageSet.isLatest; - target.isCurrent = target.isCurrent || messageSet.isCurrent; - target.pagination.hasPrev = - messageSet.messages[0].created_at < target.messages[0].created_at - ? messageSet.pagination.hasPrev - : target.pagination.hasPrev; - target.pagination.hasNext = - target.messages.slice(-1)[0].created_at < - messageSet.messages.slice(-1)[0].created_at - ? messageSet.pagination.hasNext - : target.pagination.hasNext; - messagesToAdd = [...messagesToAdd, ...messageSet.messages]; - }); - sources.forEach((s) => this.messageSets.splice(this.messageSets.indexOf(s), 1)); - const overlappingMessageSetIndex = this.messageSets.findIndex((s) => - this.areMessageSetsOverlap(s.messages, newMessages), - ); - targetMessageSetIndex = overlappingMessageSetIndex; - } - } else { - // assumes that all new messages belong to the same set - targetMessageSetIndex = this.findMessageSetIndex(newMessages[0]); - } - - return { targetMessageSetIndex, messagesToAdd }; + // Main channel messages live in the paginator — use channel.messagePaginator.getItem. + return undefined; } } diff --git a/src/client.ts b/src/client.ts index 34e6b419e..48d7d99a3 100644 --- a/src/client.ts +++ b/src/client.ts @@ -33,7 +33,6 @@ import { isFunction, isOnline, isOwnUserBaseProperty, - messageSetPagination, normalizeQuerySort, randomId, retryInterval, @@ -2321,36 +2320,15 @@ export class StreamChat { ); } - let updatedMessagesSet; - let filteredMessageIds: string[] = []; if (skipInitialization === undefined) { - const { messageSet, filteredMessageIds: _filteredMessageIds } = - c._initializeState(channelState, 'latest'); - filteredMessageIds = _filteredMessageIds; - updatedMessagesSet = messageSet; + c._initializeState(channelState); } else if (!skipInitialization.includes(channelState.channel.id)) { + // clearMessages() resets the pinned cache; the paginator is (re)seeded above. c.state.clearMessages(); - const { messageSet, filteredMessageIds: _filteredMessageIds } = - c._initializeState(channelState, 'latest'); - filteredMessageIds = _filteredMessageIds; - updatedMessagesSet = messageSet; + c._initializeState(channelState); } - if (updatedMessagesSet) { - updatedMessagesSet.pagination = { - ...updatedMessagesSet.pagination, - ...messageSetPagination({ - parentSet: updatedMessagesSet, - requestedPageSize: - queryChannelsOptions?.message_limit || - DEFAULT_QUERY_CHANNELS_MESSAGE_LIST_PAGE_SIZE, - returnedPage: channelState.messages, - filteredReturnedPage: channelState.messages.filter( - (m) => !filteredMessageIds.includes(m.id), - ), - logger: this.logger, - }), - }; + if (willInitialize) { this.polls.hydratePollCache(channelState.messages, true); this.reminders.hydrateState(channelState.messages); } diff --git a/src/utils.ts b/src/utils.ts index 3176dfe22..c6f7e51ab 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -7,12 +7,9 @@ import type { ChannelSortBase, LocalMessage, LocalMessageBase, - Logger, Message, - MessagePaginationOptions, MessageResponse, MessageResponseBase, - MessageSet, OwnUserBase, OwnUserResponse, PromoteChannelParams, @@ -834,15 +831,6 @@ export const uniqBy = ( }); }; -type MessagePaginationUpdatedParams = { - parentSet: MessageSet; - requestedPageSize: number; - returnedPage: MessageResponse[]; - filteredReturnedPage: MessageResponse[]; - logger?: Logger; - messagePaginationOptions?: MessagePaginationOptions; -}; - export function binarySearchByDateEqualOrNearestGreater( array: { created_at?: string; @@ -873,225 +861,6 @@ export function binarySearchByDateEqualOrNearestGreater( return left; } -const messagePaginationCreatedAtAround = ({ - parentSet, - requestedPageSize, - returnedPage, - filteredReturnedPage, - messagePaginationOptions, -}: MessagePaginationUpdatedParams) => { - const newPagination = { ...parentSet.pagination }; - if (!messagePaginationOptions?.created_at_around) return newPagination; - let hasPrev; - let hasNext; - let updateHasPrev; - let updateHasNext; - const createdAtAroundDate = new Date(messagePaginationOptions.created_at_around); - const [firstPageMsg, lastPageMsg] = [returnedPage[0], returnedPage.slice(-1)[0]]; - - // expect ASC order (from oldest to newest) - const wholePageHasNewerMessages = - !!firstPageMsg?.created_at && new Date(firstPageMsg.created_at) > createdAtAroundDate; - const wholePageHasOlderMessages = - !!lastPageMsg?.created_at && new Date(lastPageMsg.created_at) < createdAtAroundDate; - - const requestedPageSizeNotMet = - requestedPageSize > parentSet.messages.length && - requestedPageSize > returnedPage.length; - const noMoreMessages = - (requestedPageSize > parentSet.messages.length || - parentSet.messages.length >= returnedPage.length) && - requestedPageSize > returnedPage.length; - - if (wholePageHasNewerMessages) { - hasPrev = false; - updateHasPrev = true; - if (requestedPageSizeNotMet) { - hasNext = false; - updateHasNext = true; - } - } else if (wholePageHasOlderMessages) { - hasNext = false; - updateHasNext = true; - if (requestedPageSizeNotMet) { - hasPrev = false; - updateHasPrev = true; - } - } else if (noMoreMessages) { - hasNext = hasPrev = false; - updateHasPrev = updateHasNext = true; - } else { - const [firstFilteredPageMsg, lastFilteredPageMsg] = [ - filteredReturnedPage[0], - filteredReturnedPage.slice(-1)[0], - ]; - const [firstPageMsgIsFirstInSet, lastPageMsgIsLastInSet] = [ - firstFilteredPageMsg?.id && firstFilteredPageMsg.id === parentSet.messages[0]?.id, - lastFilteredPageMsg?.id && - lastFilteredPageMsg.id === parentSet.messages.slice(-1)[0]?.id, - ]; - updateHasPrev = firstPageMsgIsFirstInSet; - updateHasNext = lastPageMsgIsLastInSet; - const midPointByCount = Math.floor(returnedPage.length / 2); - const midPointByCreationDate = binarySearchByDateEqualOrNearestGreater( - returnedPage, - createdAtAroundDate, - ); - - if (midPointByCreationDate !== -1) { - hasPrev = midPointByCount <= midPointByCreationDate; - hasNext = midPointByCount >= midPointByCreationDate; - } - } - - if (updateHasPrev && typeof hasPrev !== 'undefined') newPagination.hasPrev = hasPrev; - if (updateHasNext && typeof hasNext !== 'undefined') newPagination.hasNext = hasNext; - - return newPagination; -}; - -const messagePaginationIdAround = ({ - parentSet, - requestedPageSize, - returnedPage, - filteredReturnedPage, - messagePaginationOptions, -}: MessagePaginationUpdatedParams) => { - const newPagination = { ...parentSet.pagination }; - const { id_around } = messagePaginationOptions || {}; - if (!id_around) return newPagination; - let hasPrev; - let hasNext; - - const [firstFilteredPageMsg, lastFilteredPageMsg] = [ - filteredReturnedPage[0], - filteredReturnedPage.slice(-1)[0], - ]; - const [firstPageMsgIsFirstInSet, lastPageMsgIsLastInSet] = [ - firstFilteredPageMsg?.id === parentSet.messages[0]?.id, - lastFilteredPageMsg?.id === parentSet.messages.slice(-1)[0]?.id, - ]; - let updateHasPrev = firstPageMsgIsFirstInSet; - let updateHasNext = lastPageMsgIsLastInSet; - - const midPoint = Math.floor(returnedPage.length / 2); - const noMoreMessages = - (requestedPageSize > parentSet.messages.length || - parentSet.messages.length >= returnedPage.length) && - requestedPageSize > returnedPage.length; - - if (noMoreMessages) { - hasNext = hasPrev = false; - updateHasPrev = updateHasNext = true; - } else if (!returnedPage[midPoint]) { - return newPagination; - } else if (returnedPage[midPoint].id === id_around) { - hasPrev = hasNext = true; - } else { - let targetMsg; - const halves = [returnedPage.slice(0, midPoint), returnedPage.slice(midPoint)]; - hasPrev = hasNext = true; - for (let i = 0; i < halves.length; i++) { - targetMsg = halves[i].find((message) => message.id === id_around); - if (targetMsg && i === 0) { - hasPrev = false; - } - if (targetMsg && i === 1) { - hasNext = false; - } - } - } - - if (updateHasPrev && typeof hasPrev !== 'undefined') newPagination.hasPrev = hasPrev; - if (updateHasNext && typeof hasNext !== 'undefined') newPagination.hasNext = hasNext; - - return newPagination; -}; - -const messagePaginationLinear = ({ - parentSet, - requestedPageSize, - returnedPage, - filteredReturnedPage, - messagePaginationOptions, -}: MessagePaginationUpdatedParams) => { - const newPagination = { ...parentSet.pagination }; - - let hasPrev; - let hasNext; - - const [firstFilteredPageMsg, lastFilteredPageMsg] = [ - filteredReturnedPage[0], - filteredReturnedPage.slice(-1)[0], - ]; - const [firstPageMsgIsFirstInSet, lastPageMsgIsLastInSet] = [ - firstFilteredPageMsg?.id && firstFilteredPageMsg.id === parentSet.messages[0]?.id, - lastFilteredPageMsg?.id && - lastFilteredPageMsg.id === parentSet.messages.slice(-1)[0]?.id, - ]; - - const queriedNextMessages = - messagePaginationOptions && - (messagePaginationOptions.created_at_after_or_equal || - messagePaginationOptions.created_at_after || - messagePaginationOptions.id_gt || - messagePaginationOptions.id_gte); - - const queriedPrevMessages = - typeof messagePaginationOptions === 'undefined' - ? true - : messagePaginationOptions.created_at_before_or_equal || - messagePaginationOptions.created_at_before || - messagePaginationOptions.id_lt || - messagePaginationOptions.id_lte || - messagePaginationOptions.offset; - - const containsUnrecognizedOptionsOnly = - !queriedNextMessages && - !queriedPrevMessages && - !messagePaginationOptions?.id_around && - !messagePaginationOptions?.created_at_around; - - const hasMore = returnedPage.length >= requestedPageSize; - - if (typeof queriedPrevMessages !== 'undefined' || containsUnrecognizedOptionsOnly) { - hasPrev = hasMore; - } - if (typeof queriedNextMessages !== 'undefined') { - hasNext = hasMore; - } - const returnedPageIsEmpty = returnedPage.length === 0; - - if ((firstPageMsgIsFirstInSet || returnedPageIsEmpty) && typeof hasPrev !== 'undefined') - newPagination.hasPrev = hasPrev; - if ((lastPageMsgIsLastInSet || returnedPageIsEmpty) && typeof hasNext !== 'undefined') - newPagination.hasNext = hasNext; - - return newPagination; -}; - -export const messageSetPagination = (params: MessagePaginationUpdatedParams) => { - if ( - params.parentSet.messages.length + - (params.returnedPage.length - params.filteredReturnedPage.length) < - params.returnedPage.length - ) { - params.logger?.( - 'error', - 'Corrupted message set state: parent set size < returned page size', - ); - return params.parentSet.pagination; - } - - if (params.messagePaginationOptions?.created_at_around) { - return messagePaginationCreatedAtAround(params); - } else if (params.messagePaginationOptions?.id_around) { - return messagePaginationIdAround(params); - } else { - return messagePaginationLinear(params); - } -}; - /** * A utility object used to prevent duplicate invocation of channel.watch() to be triggered when * 'notification.message_new' and 'notification.added_to_channel' events arrive at the same time. From c238323f84289c722b4ae32857f843c9b1925b49 Mon Sep 17 00:00:00 2001 From: martincupela Date: Mon, 20 Jul 2026 14:34:33 +0200 Subject: [PATCH 03/25] test: align unit suite with main message-list removal Complete the test surgery for the channel_state main message-list removal (caba066b): delete message-set machinery specs (pruning, merges, latestMessages, messagePagination, main-list addMessagesSorted/findMessage/reactions), and re-scope the surviving deleteUser/ updateUser and #1736 self-quote coverage to threads + pinned. Migrate channel/client event and query/queryChannels tests to assert against channel.messagePaginator. Co-Authored-By: Claude Opus 4.8 --- test/unit/channel.test.js | 79 +- test/unit/channel_state.test.js | 1316 ++----------- test/unit/client.test.js | 78 +- test/unit/utils.test.js | 3050 ------------------------------- 4 files changed, 230 insertions(+), 4293 deletions(-) diff --git a/test/unit/channel.test.js b/test/unit/channel.test.js index 97dfb373b..27ac86cde 100644 --- a/test/unit/channel.test.js +++ b/test/unit/channel.test.js @@ -1129,11 +1129,8 @@ describe('Channel _handleChannelEvent', function () { channel.state.addMessagesSorted(thread1); }; - it('removes the messages on hard delete', () => { + it('removes the pinned and thread messages on hard delete', () => { setupChannel(channel); - expect(channel.state.messageSets).toHaveLength(2); - expect(channel.state.messageSets[0].messages).toHaveLength(messageSet1.length); - expect(channel.state.messageSets[1].messages).toHaveLength(messageSet2.length); expect(channel.state.pinnedMessages).toHaveLength(pinnedMessages.length); expect(channel.state.threads[parent_id]).toHaveLength(thread1.length); @@ -1147,7 +1144,6 @@ describe('Channel _handleChannelEvent', function () { created_at: '2025-02-01T14:01:30.000Z', }; channel._handleChannelEvent(event); - expect(channel.state.messageSets[0].messages).toHaveLength(3); const check = (message) => { const deletedMessage = { @@ -1185,16 +1181,11 @@ describe('Channel _handleChannelEvent', function () { } }; - channel.state.messageSets[0].messages.forEach(check); - channel.state.messageSets[1].messages.forEach(check); channel.state.pinnedMessages.forEach(check); Object.values(channel.state.threads).forEach((replies) => replies.forEach(check)); }); - it('removes the messages on soft delete', () => { + it('removes the pinned and thread messages on soft delete', () => { setupChannel(channel); - expect(channel.state.messageSets).toHaveLength(2); - expect(channel.state.messageSets[0].messages).toHaveLength(messageSet1.length); - expect(channel.state.messageSets[1].messages).toHaveLength(messageSet2.length); expect(channel.state.pinnedMessages).toHaveLength(pinnedMessages.length); expect(channel.state.threads[parent_id]).toHaveLength(thread1.length); @@ -1208,7 +1199,6 @@ describe('Channel _handleChannelEvent', function () { created_at: '2025-02-01T14:01:30.000Z', }; channel._handleChannelEvent(event); - expect(channel.state.messageSets[0].messages).toHaveLength(3); const check = (message) => { if (message.user.id === bannedUser.id) { @@ -1233,8 +1223,6 @@ describe('Channel _handleChannelEvent', function () { } }; - channel.state.messageSets[0].messages.forEach(check); - channel.state.messageSets[1].messages.forEach(check); channel.state.pinnedMessages.forEach(check); Object.values(channel.state.threads).forEach((replies) => replies.forEach(check)); }); @@ -1331,7 +1319,11 @@ describe('Channel _handleChannelEvent', function () { quoted_message: m1, quoted_message_id: m1.id, }); - channel.state.addMessagesSorted([m1, m2]); + channel.messagePaginator.setItems({ + valueOrFactory: [m1, m2], + isFirstPage: true, + isLastPage: true, + }); const event = { type: 'user.messages.deleted', @@ -1345,11 +1337,12 @@ describe('Channel _handleChannelEvent', function () { expect(() => channel._handleChannelEvent(event)).not.to.throw(); - const messages = channel.state.messageSets[0].messages; - expect(messages.find((m) => m.id === m1.id).type).to.equal('deleted'); - const quoter = messages.find((m) => m.id === m2.id); - expect(quoter.type).to.equal('deleted'); - expect(quoter.quoted_message).to.equal(undefined); + // Both messages belong to the banned user, so a hard delete drops both from the + // active window. The point of the regression is that the self-quote (m2 → m1) does + // not throw while doing so. + const items = channel.messagePaginator.items ?? []; + expect(items.find((m) => m.id === m1.id)).to.equal(undefined); + expect(items.find((m) => m.id === m2.id)).to.equal(undefined); }); }); @@ -1853,9 +1846,10 @@ describe('Channel _handleChannelEvent', function () { channel._handleChannelEvent(event); channel._callChannelListeners(event); - expect( - channel.state.findMessage(message.id, message.parent_id).own_reactions.length, - ).to.equal(own_reactions.length); + const stored = message.parent_id + ? channel.state.findMessage(message.id, message.parent_id) + : channel.messagePaginator.getItem(message.id); + expect(stored.own_reactions.length).to.equal(own_reactions.length); expect(receivedEvent.message.own_reactions.length).to.equal(own_reactions.length); }); }); @@ -1893,17 +1887,19 @@ describe('Channel _handleChannelEvent', function () { ['message.updated', 'message.deleted'].forEach((eventType) => { channel.state.addMessagesSorted(messages); const isThread = messages.length === 3; + if (!isThread) seedLatestWindow(channel, messages); const quotingMessage = messages[messages.length - 1]; const event = { type: eventType, message: isThread ? updatedQuotedThreadReply : updatedQuotedMessage, }; channel._handleChannelEvent(event); - expect( - channel.state.findMessage(quotingMessage.id, quotingMessage.parent_id) - .quoted_message.text, - ).to.equal(updatedQuotedMessage.text); + const stored = isThread + ? channel.state.findMessage(quotingMessage.id, quotingMessage.parent_id) + : channel.messagePaginator.getItem(quotingMessage.id); + expect(stored.quoted_message.text).to.equal(updatedQuotedMessage.text); channel.state.clearMessages(); + channel.messagePaginator.clearStateAndCache(); }); }); }); @@ -2818,7 +2814,8 @@ describe('Channel.query', async () => { ...mockChannelQueryResponse, messages: Array.from( { length: DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE }, - generateMsg, + (_, i) => + generateMsg({ created_at: new Date(1700000000000 + i * 1000).toISOString() }), ), }; const mock = sinon.mock(client); @@ -2828,7 +2825,7 @@ describe('Channel.query', async () => { mock.restore(); }); - it('should update pagination for queried message set to prevent more pagination', async () => { + it('seeds the message paginator with the full latest page on query', async () => { const client = await getClientWithUser(); const channel = client.channel('messaging', uuidv4()); const mockedChannelQueryResponse = { @@ -2840,16 +2837,16 @@ describe('Channel.query', async () => { }; const mock = sinon.mock(client); mock.expects('post').returns(Promise.resolve(mockedChannelQueryResponse)); - await channel.query(); - expect(channel.state.messageSets.length).to.be.equal(1); - expect(channel.state.messageSets[0].pagination).to.eql({ - hasNext: false, - hasPrev: true, - }); + await channel.query({}, 'latest'); + // A latest-page query seeds the message paginator with the returned page. + expect(channel.messagePaginator.items).to.have.length( + DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE, + ); + expect(channel.messagePaginator.latestItem).to.not.equal(undefined); mock.restore(); }); - it('should not update pagination for queried message set', async () => { + it('seeds the message paginator with a partial latest page on query', async () => { const client = await getClientWithUser(); const channel = client.channel('messaging', uuidv4()); const mockedChannelQueryResponse = { @@ -2861,12 +2858,10 @@ describe('Channel.query', async () => { }; const mock = sinon.mock(client); mock.expects('post').returns(Promise.resolve(mockedChannelQueryResponse)); - await channel.query(); - expect(channel.state.messageSets.length).to.be.equal(1); - expect(channel.state.messageSets[0].pagination).to.eql({ - hasNext: false, - hasPrev: false, - }); + await channel.query({}, 'latest'); + expect(channel.messagePaginator.items).to.have.length( + DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE - 1, + ); mock.restore(); }); diff --git a/test/unit/channel_state.test.js b/test/unit/channel_state.test.js index 40f3914cd..fdd93ea9b 100644 --- a/test/unit/channel_state.test.js +++ b/test/unit/channel_state.test.js @@ -5,7 +5,6 @@ import { getClientWithUser } from './test-utils/getClient'; import { getOrCreateChannelApi } from './test-utils/getOrCreateChannelApi'; import { ChannelState, StreamChat, Channel } from '../../src'; -import { DEFAULT_MESSAGE_SET_PAGINATION } from '../../src/constants'; import { generateUUIDv4 as uuidv4 } from '../../src/utils'; import { vi, describe, beforeEach, afterEach, it, expect } from 'vitest'; @@ -28,655 +27,6 @@ describe('ChannelState addMessagesSorted', function () { state = new ChannelState(channel); }); - it('empty state add single messages', async function () { - expect(state.messages).to.have.length(0); - state.addMessagesSorted([generateMsg({ id: '0', date: '2020-01-01T00:00:00.000Z' })]); - expect(state.messages).to.have.length(1); - state.addMessagesSorted([generateMsg({ id: '1', date: '2020-01-01T00:00:01.000Z' })]); - - expect(state.messages).to.have.length(2); - expect(state.messages[0].id).to.be.equal('0'); - expect(state.messages[1].id).to.be.equal('1'); - }); - - it('should not add messages from shadow banned users', () => { - state.addMessagesSorted([generateMsg({ shadowed: true })]); - - expect(state.messages).to.be.empty; - }); - - it('updates an existing message with shadowed: true when applying a message update', () => { - state.addMessagesSorted([generateMsg({ id: 'shadow-update-msg' })]); - - expect(state.messages).to.have.length(1); - expect(state.messages[0].shadowed).not.to.be.ok; - - state.addMessageSorted({ ...state.messages[0], shadowed: true }, false, false); - - expect(state.messages).to.have.length(1); - expect(state.messages[0].id).to.be.equal('shadow-update-msg'); - expect(state.messages[0].shadowed).to.be.equal(true); - }); - - it('empty state add multiple messages', async function () { - state.addMessagesSorted([ - generateMsg({ id: '1', date: '2020-01-01T00:00:00.001Z' }), - generateMsg({ id: '2', date: '2020-01-01T00:00:00.002Z' }), - generateMsg({ id: '0', date: '2020-01-01T00:00:00.000Z' }), - ]); - - expect(state.messages).to.have.length(3); - expect(state.messages[0].id).to.be.equal('0'); - expect(state.messages[1].id).to.be.equal('1'); - expect(state.messages[2].id).to.be.equal('2'); - }); - - it('update a message in place 1', async function () { - state.addMessagesSorted([generateMsg({ id: '0' })]); - state.addMessagesSorted([{ ...state.messages[0], text: 'update' }]); - - expect(state.messages).to.have.length(1); - expect(state.messages[0].text).to.be.equal('update'); - }); - - it('update a message in place 2', async function () { - state.addMessagesSorted([ - generateMsg({ id: '1', date: '2020-01-01T00:00:00.001Z' }), - generateMsg({ id: '2', date: '2020-01-01T00:00:00.002Z' }), - generateMsg({ id: '0', date: '2020-01-01T00:00:00.000Z' }), - ]); - - state.addMessagesSorted([{ ...state.messages[1], text: 'update' }]); - - expect(state.messages).to.have.length(3); - expect(state.messages[1].text).to.be.equal('update'); - expect(state.messages[0].id).to.be.equal('0'); - expect(state.messages[1].id).to.be.equal('1'); - expect(state.messages[2].id).to.be.equal('2'); - }); - - it('update a message in place 3', async function () { - state.addMessagesSorted([ - generateMsg({ id: '1', date: '2020-01-01T00:00:00.001Z' }), - generateMsg({ id: '2', date: '2020-01-01T00:00:00.002Z' }), - generateMsg({ id: '0', date: '2020-01-01T00:00:00.000Z' }), - generateMsg({ id: '3', date: '2020-01-01T00:00:00.003Z' }), - ]); - - state.addMessagesSorted([{ ...state.messages[0], text: 'update 0' }]); - expect(state.messages).to.have.length(4); - expect(state.messages[0].text).to.be.equal('update 0'); - - state.addMessagesSorted([{ ...state.messages[2], text: 'update 2' }]); - expect(state.messages).to.have.length(4); - expect(state.messages[2].text).to.be.equal('update 2'); - - state.addMessagesSorted([{ ...state.messages[3], text: 'update 3' }]); - expect(state.messages).to.have.length(4); - expect(state.messages[3].text).to.be.equal('update 3'); - }); - - it('add a message with same created_at', async function () { - for (let i = 0; i < 10; i++) { - state.addMessagesSorted([ - generateMsg({ id: `${i}`, date: `2020-01-01T00:00:00.00${i}Z` }), - ]); - } - - for (let i = 10; i < state.messages.length - 1; i++) { - for (let j = i + 1; i < state.messages.length - 1; j++) - expect(state.messages[i].created_at.getTime()).to.be.lessThan( - state.messages[j].created_at.getTime(), - ); - } - - expect(state.messages).to.have.length(10); - state.addMessagesSorted([ - generateMsg({ id: 'id', date: `2020-01-01T00:00:00.007Z` }), - ]); - expect(state.messages).to.have.length(11); - expect(state.messages[7].id).to.be.equal('7'); - expect(state.messages[8].id).to.be.equal('id'); - }); - - it('add lots of messages in order', async function () { - for (let i = 100; i < 300; i++) { - state.addMessagesSorted([ - generateMsg({ id: `${i}`, date: `2020-01-01T00:00:00.${i}Z` }), - ]); - } - - expect(state.messages).to.have.length(200); - for (let i = 100; i < state.messages.length - 1; i++) { - for (let j = i + 1; j < state.messages.length - 1; j++) - expect(state.messages[i].created_at.getTime()).to.be.lessThan( - state.messages[j].created_at.getTime(), - ); - } - }); - - it('add lots of messages out of order', async function () { - const messages = []; - for (let i = 100; i < 300; i++) { - messages.push(generateMsg({ id: `${i}`, date: `2020-01-01T00:00:00.${i}Z` })); - } - // shuffle - for (let i = messages.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [messages[i], messages[j]] = [messages[j], messages[i]]; - } - - state.addMessagesSorted(messages); - - expect(state.messages).to.have.length(200); - for (let i = 0; i < 200; i++) { - expect(state.messages[i].id).to.be.equal(`${i + 100}`); - } - }); - - it('should avoid duplicates if message.created_at changes', async function () { - state.addMessagesSorted([generateMsg({ id: '0', date: '2020-01-01T00:00:00.000Z' })]); - expect(state.messages).to.have.length(1); - - state.addMessageSorted( - { - ...state.messages[0], - created_at: '2020-01-01T00:00:00.044Z', - text: 'update 0', - }, - true, - ); - expect(state.messages).to.have.length(1); - expect(state.messages[0].text).to.be.equal('update 0'); - expect(state.messages[0].created_at.getTime()).to.be.equal( - new Date('2020-01-01T00:00:00.044Z').getTime(), - ); - }); - - it('should respect order and avoid duplicates if message.created_at changes', async function () { - state.addMessagesSorted([ - generateMsg({ id: '1', date: '2020-01-01T00:00:00.001Z' }), - generateMsg({ id: '2', date: '2020-01-01T00:00:00.002Z' }), - generateMsg({ id: '0', date: '2020-01-01T00:00:00.000Z' }), - generateMsg({ id: '3', date: '2020-01-01T00:00:00.003Z' }), - ]); - expect(state.messages).to.have.length(4); - - state.addMessagesSorted( - [ - { - ...state.messages[3], - created_at: '2020-01-01T00:00:00.033Z', - text: 'update 3', - }, - ], - true, - ); - expect(state.messages).to.have.length(4); - expect(state.messages[3].text).to.be.equal('update 3'); - - state.addMessageSorted( - { - ...state.messages[0], - created_at: '2020-01-01T00:00:00.044Z', - text: 'update 0', - }, - true, - ); - expect(state.messages).to.have.length(4); - expect(state.messages[3].text).to.be.equal('update 0'); - expect(state.messages[0].id).to.be.equal('1'); - expect(state.messages[1].id).to.be.equal('2'); - expect(state.messages[2].id).to.be.equal('3'); - expect(state.messages[3].id).to.be.equal('0'); - }); - - it('should add messages to new message set', () => { - state.addMessagesSorted([ - generateMsg({ id: '12', date: toISOString(100) }), - generateMsg({ id: '13', date: toISOString(200) }), - generateMsg({ id: '14', date: toISOString(300) }), - ]); - state.addMessagesSorted( - [ - generateMsg({ id: '0', date: toISOString(1000) }), - generateMsg({ id: '1', date: toISOString(1100) }), - ], - false, - false, - true, - 'new', - ); - - expect(state.messages.length).to.be.equal(3); - expect(state.messages[0].id).to.be.equal('12'); - expect(state.messages[1].id).to.be.equal('13'); - expect(state.messages[2].id).to.be.equal('14'); - // set with ids 0,1 is added at the beginning as the newest set is inserted earlier - expect(state.messageSets[0].messages.map((m) => m.id)).toStrictEqual(['0', '1']); - expect(state.messageSets[1].messages.map((m) => m.id)).toStrictEqual([ - '12', - '13', - '14', - ]); - }); - - it('should add messages to current message set', () => { - state.addMessagesSorted( - [generateMsg({ id: '12' }), generateMsg({ id: '13' }), generateMsg({ id: '14' })], - false, - false, - true, - 'current', - ); - - expect(state.messages.length).to.be.equal(3); - expect(state.messages[0].id).to.be.equal('12'); - expect(state.messages[1].id).to.be.equal('13'); - expect(state.messages[2].id).to.be.equal('14'); - }); - - it('should add messages to latest message set', () => { - state.addMessagesSorted( - [generateMsg({ id: '12' }), generateMsg({ id: '13' }), generateMsg({ id: '14' })], - false, - false, - true, - 'latest', - ); - - expect(state.messages.length).to.be.equal(3); - expect(state.messages[0].id).to.be.equal('12'); - expect(state.messages[1].id).to.be.equal('13'); - expect(state.messages[2].id).to.be.equal('14'); - expect(state.latestMessages.length).to.be.equal(3); - expect(state.latestMessages[0].id).to.be.equal('12'); - expect(state.latestMessages[1].id).to.be.equal('13'); - expect(state.latestMessages[2].id).to.be.equal('14'); - }); - - it('adds message page sorted', () => { - // load first page - state.addMessagesSorted( - [ - generateMsg({ id: '12', date: toISOString(1200) }), - generateMsg({ id: '13', date: toISOString(1300) }), - generateMsg({ id: '14', date: toISOString(1400) }), - ], - false, - false, - true, - 'latest', - ); - - // jump to a start - state.addMessagesSorted( - [ - generateMsg({ id: '1', date: toISOString(100) }), - generateMsg({ id: '2', date: toISOString(200) }), - ], - false, - false, - true, - 'new', - ); - state.messageSets[0].isCurrent = false; - state.messageSets[1].isCurrent = true; - // jump to a end - - state.addMessagesSorted( - [generateMsg({ id: '10', date: toISOString(1000) })], - false, - false, - true, - 'new', - ); - - state.addMessagesSorted( - [ - generateMsg({ id: '8', date: toISOString(800) }), - generateMsg({ id: '9', date: toISOString(900) }), - ], - false, - false, - true, - 'new', - ); - - state.addMessagesSorted( - [ - generateMsg({ id: '4', date: toISOString(400) }), - generateMsg({ id: '5', date: toISOString(500) }), - generateMsg({ id: '6', date: toISOString(600) }), - ], - false, - false, - true, - 'new', - ); - - state.addMessagesSorted( - [generateMsg({ id: '1500', date: toISOString(1500) })], - false, - false, - true, - 'new', - ); - - const toTimestamp = (msg) => new Date(msg.created_at).getTime(); - expect(state.messageSets.length).to.eql(6); - expect(state.messageSets[0].messages.map(toTimestamp)).toStrictEqual([1500]); - expect(state.messageSets[1].messages.map(toTimestamp)).toStrictEqual([ - 1200, 1300, 1400, - ]); - expect(state.messageSets[2].messages.map(toTimestamp)).toStrictEqual([1000]); - expect(state.messageSets[3].messages.map(toTimestamp)).toStrictEqual([800, 900]); - expect(state.messageSets[4].messages.map(toTimestamp)).toStrictEqual([400, 500, 600]); - expect(state.messageSets[5].messages.map(toTimestamp)).toStrictEqual([100, 200]); - }); - - it('inputs messages pertaining to different sets into corresponding message set and breaks the state', () => { - // load first page - state.addMessagesSorted( - [ - generateMsg({ id: '12', date: toISOString(1200) }), - generateMsg({ id: '14', date: toISOString(1400) }), - ], - false, - false, - true, - 'latest', - ); - - state.addMessagesSorted( - [ - generateMsg({ id: '6', date: toISOString(600) }), - generateMsg({ id: '8', date: toISOString(800) }), - ], - false, - false, - true, - 'new', - ); - - state.addMessagesSorted( - [ - generateMsg({ id: '1', date: toISOString(100) }), - generateMsg({ id: '3', date: toISOString(300) }), - ], - false, - false, - true, - 'new', - ); - - state.addMessagesSorted( - [ - generateMsg({ id: '7', date: 700 }), - generateMsg({ id: '2', date: 200 }), - generateMsg({ id: '13', date: toISOString(1300) }), - ], - false, - false, - true, - 'new', - ); - - const toTimestamp = (msg) => new Date(msg.created_at).getTime(); - expect(state.messageSets.length).to.eql(4); - expect(state.messageSets[0].messages.map(toTimestamp)).toStrictEqual([1200, 1400]); - expect(state.messageSets[1].messages.map(toTimestamp)).toStrictEqual([ - 200, 700, 1300, - ]); - expect(state.messageSets[2].messages.map(toTimestamp)).toStrictEqual([600, 800]); - expect(state.messageSets[3].messages.map(toTimestamp)).toStrictEqual([100, 300]); - }); - - it(`should add messages to latest message set when it's not currently active`, () => { - state.addMessagesSorted( - [ - generateMsg({ id: '12', date: toISOString(1200) }), - generateMsg({ id: '13', date: toISOString(1300) }), - generateMsg({ id: '14', date: toISOString(1400) }), - ], - false, - false, - true, - 'latest', - ); - state.addMessagesSorted( - [ - generateMsg({ id: '1', date: toISOString(100) }), - generateMsg({ id: '2', date: toISOString(200) }), - ], - false, - false, - true, - 'new', - ); - state.messageSets[0].isCurrent = false; - state.messageSets[1].isCurrent = true; - state.addMessagesSorted( - [generateMsg({ id: '15', date: toISOString(1500) })], - false, - false, - true, - 'latest', - ); - - expect(state.latestMessages.map((m) => m.id)).toStrictEqual(['12', '13', '14', '15']); - }); - - it('adjusts the latest set flag according to actual message creation date', () => { - state.addMessagesSorted( - [ - generateMsg({ id: '1', date: toISOString(100) }), - generateMsg({ id: '2', date: toISOString(200) }), - ], - false, - false, - true, - 'latest', - ); - expect(state.latestMessages.map((m) => m.id)).toStrictEqual(['1', '2']); - - state.addMessagesSorted( - [ - generateMsg({ id: '12', date: toISOString(1200) }), - generateMsg({ id: '13', date: toISOString(1300) }), - generateMsg({ id: '14', date: toISOString(1400) }), - ], - false, - false, - true, - 'new', - ); - expect(state.latestMessages.map((m) => m.id)).toStrictEqual(['12', '13', '14']); - expect(state.messageSets.filter((s) => s.isLatest).length).toBe(1); - }); - - it("the messageSetToAddToIfDoesNotExist: 'latest' should be ignored if the messages do not belong to the latest set based on their creation timestamp", () => { - state.addMessagesSorted( - [ - generateMsg({ id: '12', date: toISOString(1200) }), - generateMsg({ id: '13', date: toISOString(1300) }), - generateMsg({ id: '14', date: toISOString(1400) }), - ], - false, - false, - true, - 'latest', - ); - state.addMessagesSorted( - [ - generateMsg({ id: '1', date: toISOString(100) }), - generateMsg({ id: '2', date: toISOString(200) }), - ], - false, - false, - true, - 'new', - ); - expect(state.messageSets[0].isCurrent).toBeTruthy(); - expect(state.messageSets[1].isCurrent).toBeFalsy(); - - state.addMessagesSorted( - [generateMsg({ id: '15', date: toISOString(150) })], - false, - false, - true, - 'latest', - ); - - expect(state.messageSets[0].messages.map((m) => m.id)).toStrictEqual([ - '12', - '13', - '14', - ]); - expect(state.latestMessages.map((m) => m.id)).toStrictEqual(['12', '13', '14']); - expect(state.messageSets[1].messages.map((m) => m.id)).toStrictEqual([ - '1', - '15', - '2', - ]); - }); - - it(`shouldn't create new message set for thread replies`, () => { - state.addMessagesSorted( - [ - generateMsg({ parent_id: '12' }), - generateMsg({ parent_id: '12' }), - generateMsg({ parent_id: '12' }), - ], - false, - false, - true, - 'new', - ); - - expect(state.messageSets.length).to.be.equal(1); - }); - - it(`should update message in non-active message set`, () => { - state.addMessagesSorted([ - generateMsg({ id: '12' }), - generateMsg({ id: '13' }), - generateMsg({ id: '14' }), - ]); - state.addMessagesSorted( - [generateMsg({ id: '0', date: '2020-01-01T00:00:00.000Z' })], - false, - false, - true, - 'new', - ); - state.addMessagesSorted( - [ - generateMsg({ - id: '0', - date: '2020-01-01T00:00:00.000Z', - text: 'Updated text', - }), - ], - false, - false, - false, - ); - - expect(state.messages.length).to.be.equal(3); - expect(state.messageSets[1].messages.length).to.be.equal(1); - expect(state.messageSets[1].messages[0].text).to.be.equal('Updated text'); - }); - - it(`should update message in active message set`, () => { - state.addMessagesSorted([ - generateMsg({ id: '12', date: '2020-01-01T00:00:00.000Z' }), - generateMsg({ id: '13', date: '2020-01-01T00:00:10.000Z' }), - generateMsg({ id: '14', date: '2020-01-01T00:00:11.000Z' }), - ]); - state.addMessagesSorted( - [ - generateMsg({ - id: '13', - date: '2020-01-01T00:00:10.000Z', - text: 'Updated text', - }), - ], - false, - false, - false, - ); - - expect(state.messages.length).to.be.equal(3); - expect(state.messages[1].text).to.be.equal('Updated text'); - expect(state.messageSets.length).to.be.equal(1); - }); - - it(`should update message in latest message set`, () => { - state.addMessagesSorted( - [ - generateMsg({ id: '12', date: '2020-01-01T00:00:00.000Z' }), - generateMsg({ id: '13', date: '2020-01-01T00:00:10.000Z' }), - generateMsg({ id: '14', date: '2020-01-01T00:00:11.000Z' }), - ], - false, - false, - true, - 'latest', - ); - state.addMessagesSorted( - [ - generateMsg({ - id: '13', - date: '2020-01-01T00:00:10.000Z', - text: 'Updated text', - }), - ], - false, - false, - false, - ); - - expect(state.latestMessages.length).to.be.equal(3); - expect(state.latestMessages[1].text).to.be.equal('Updated text'); - }); - - it(`should do nothing if message is not available locally`, () => { - state.addMessagesSorted([ - generateMsg({ id: '12', date: toISOString(1200) }), - generateMsg({ id: '13', date: toISOString(1300) }), - generateMsg({ id: '14', date: toISOString(1400) }), - ]); - state.addMessagesSorted( - [generateMsg({ id: '5', date: toISOString(500) })], - false, - false, - true, - 'new', - ); - state.addMessagesSorted( - [ - generateMsg({ id: '1', date: toISOString(100) }), - generateMsg({ id: '2', date: toISOString(200) }), - ], - false, - false, - true, - 'new', - ); - state.addMessagesSorted( - [generateMsg({ id: '8', date: toISOString(800) })], - false, - false, - false, - ); - - expect(state.latestMessages.length).to.be.equal(3); - expect(state.messages.length).to.be.equal(3); - expect(state.messageSets[1].messages.length).to.be.equal(1); - expect(state.messageSets[2].messages.length).to.be.equal(2); - }); - it('updates last_message_at correctly', async function () { expect(state.last_message_at).to.be.null; state.addMessagesSorted([generateMsg({ id: '0', date: '2020-01-01T00:00:00.000Z' })]); @@ -713,17 +63,6 @@ describe('ChannelState addMessagesSorted', function () { expect(state.pinnedMessages[2].id).to.be.equal('2'); }); - it('should add message preview', async function () { - // these message previews are used UI SDKs - const messagePreview = generateMsg({ - id: '1', - date: new Date('2020-01-01T00:00:00.001Z'), - }); - state.addMessageSorted(messagePreview); - - expect(state.messages[0].id).to.be.equal('1'); - }); - it('should add thread reply preview', async function () { // these message previews are used by UI SDKs const parentMessage = generateMsg({ @@ -742,246 +81,6 @@ describe('ChannelState addMessagesSorted', function () { expect(thread.length).to.be.equal(1); expect(thread[0].id).to.be.equal(threadReplyPreview.id); }); - - describe('merges overlapping message sets', () => { - it('when new messages overlap with latest messages', () => { - const overlap = [ - generateMsg({ id: '11', date: toISOString(1100) }), - generateMsg({ id: '12', date: toISOString(1200) }), - generateMsg({ id: '13', date: toISOString(1300) }), - ]; - const messages = [ - ...overlap, - generateMsg({ id: '14', date: toISOString(1400) }), - generateMsg({ id: '15', date: toISOString(1500) }), - ]; - state.addMessagesSorted(messages); - const newMessages = [ - generateMsg({ id: '10', date: toISOString(1000) }), - ...overlap, - ]; - state.addMessagesSorted(newMessages, false, true, true, 'new'); - - expect(state.messages.length).to.be.equal(6); - expect(state.messages[0].id).to.be.equal('10'); - expect(state.messages[1].id).to.be.equal('11'); - expect(state.messages[2].id).to.be.equal('12'); - expect(state.messages[3].id).to.be.equal('13'); - expect(state.messages[4].id).to.be.equal('14'); - expect(state.messages[5].id).to.be.equal('15'); - expect(state.messageSets.length).to.be.equal(1); - expect(state.messages).to.be.equal(state.latestMessages); - }); - - it('when new messages overlap with current messages, but not with latest messages', () => { - const overlap = [generateMsg({ id: '11', date: '2020-01-01T00:00:10.001Z' })]; - const latestMessages = [ - generateMsg({ id: '20', date: '2020-01-01T00:10:10.001Z' }), - ]; - state.addMessagesSorted(latestMessages); - const currentMessages = [ - generateMsg({ id: '10', date: '2020-01-01T00:00:03.001Z' }), - ...overlap, - ]; - state.addMessagesSorted(currentMessages, false, true, true, 'new'); - state.messageSets[0].isCurrent = false; - state.messageSets[1].isCurrent = true; - const newMessages = [ - ...overlap, - generateMsg({ id: '12', date: '2020-01-01T00:00:11.001Z' }), - ]; - state.addMessagesSorted(newMessages, false, true, true, 'new'); - - expect(state.latestMessages.length).to.be.equal(1); - expect(state.latestMessages[0].id).to.be.equal('20'); - expect(state.messages.length).to.be.equal(3); - expect(state.messages[0].id).to.be.equal('10'); - expect(state.messages[1].id).to.be.equal('11'); - expect(state.messages[2].id).to.be.equal('12'); - expect(state.messageSets.length).to.be.equal(2); - }); - - it('when new messages overlap with messages, but not current or latest messages', () => { - const overlap = [generateMsg({ id: '11', date: toISOString(1100) })]; - const latestMessages = [generateMsg({ id: '20', date: toISOString(2000) })]; - state.addMessagesSorted(latestMessages); - const currentMessages = [generateMsg({ id: '8', date: toISOString(800) })]; - state.addMessagesSorted(currentMessages, false, true, true, 'new'); - state.messageSets[0].isCurrent = false; - state.messageSets[1].isCurrent = true; - const otherMessages = [ - generateMsg({ id: '10', date: toISOString(1000) }), - ...overlap, - ]; - state.addMessagesSorted(otherMessages, false, true, true, 'new'); - const newMessages = [ - ...overlap, - generateMsg({ id: '12', date: toISOString(1200) }), - ]; - state.addMessagesSorted(newMessages, false, true, true, 'new'); - - expect(state.messageSets.length).to.be.equal(3); - expect(state.latestMessages.map(({ id }) => id)).toStrictEqual(['20']); - expect(state.messages.map(({ id }) => id)).toStrictEqual(['8']); - expect(state.messageSets.map((s) => s.messages.map(({ id }) => id))).toStrictEqual([ - ['20'], - ['10', '11', '12'], - ['8'], - ]); - }); - - it('when current messages overlap with latest', () => { - const overlap = [generateMsg({ id: '11', date: '2020-01-01T00:00:10.001Z' })]; - const latestMessages = [ - ...overlap, - generateMsg({ id: '12', date: '2020-01-01T00:01:10.001Z' }), - ]; - state.addMessagesSorted(latestMessages); - const currentMessages = [ - generateMsg({ id: '8', date: '2020-01-01T00:00:03.001Z' }), - ]; - state.addMessagesSorted(currentMessages, false, true, true, 'new'); - state.messageSets[0].isCurrent = false; - state.messageSets[1].isCurrent = true; - const newMessages = [ - generateMsg({ id: '9', date: '2020-01-01T00:00:04.001Z' }), - generateMsg({ id: '10', date: '2020-01-01T00:00:07.001Z' }), - ...overlap, - ]; - state.addMessagesSorted(newMessages, false, true, true, 'current'); - - expect(state.messages.length).to.be.equal(5); - expect(state.messages[0].id).to.be.equal('8'); - expect(state.messages[1].id).to.be.equal('9'); - expect(state.messages[2].id).to.be.equal('10'); - expect(state.messages[3].id).to.be.equal('11'); - expect(state.messages[4].id).to.be.equal('12'); - expect(state.latestMessages).to.be.equal(state.messages); - }); - - it('when new messages overlap with multiple message sets', () => { - const overlap1 = [generateMsg({ id: '11', date: '2020-01-01T00:00:10.001Z' })]; - const overlap2 = [generateMsg({ id: '13', date: '2020-01-01T00:01:10.001Z' })]; - const latestMessages = [ - ...overlap2, - generateMsg({ id: '14', date: '2020-01-01T00:01:15.001Z' }), - ]; - state.addMessagesSorted(latestMessages); - const currentMessages = [ - generateMsg({ id: '10', date: '2020-01-01T00:00:03.001Z' }), - ...overlap1, - ]; - state.addMessagesSorted(currentMessages, false, true, true, 'new'); - state.messageSets[0].isCurrent = false; - state.messageSets[0].pagination = { hasPrev: true, hasNext: false }; - state.messageSets[1].isCurrent = true; - state.messageSets[1].pagination = { hasPrev: false, hasNext: true }; - const newMessages = [ - ...overlap1, - generateMsg({ id: '12', date: '2020-01-01T00:00:14.001Z' }), - ...overlap2, - ]; - state.addMessagesSorted(newMessages, false, true, true, 'new'); - - expect(state.messages.length).to.be.equal(5); - expect(state.messages[0].id).to.be.equal('10'); - expect(state.messages[1].id).to.be.equal('11'); - expect(state.messages[2].id).to.be.equal('12'); - expect(state.messages[3].id).to.be.equal('13'); - expect(state.messages[4].id).to.be.equal('14'); - expect(state.messages).to.be.equal(state.latestMessages); - expect(state.messageSets.length).to.be.equal(1); - expect(state.messageSets[0].pagination).to.be.eql({ - hasPrev: false, - hasNext: false, - }); - }); - }); -}); - -describe('ChannelState message pruning', () => { - let channelState; - let initialMessages = []; - - beforeEach(() => { - const client = new StreamChat(); - client.userID = 'userId'; - const channel = new Channel(client, 'type', 'id', {}); - client._addChannelConfig({ cid: channel.cid, config: {} }); - channelState = new ChannelState(channel); - initialMessages = Array.from({ length: 10 }, () => - generateMsg({ date: toISOString(100) }), - ); - channelState.addMessagesSorted(initialMessages); - }); - - it('should prune messages from the end when we are in the latest set', () => { - expect(channelState.messageSets.length).to.be.equal(1); - expect(channelState.messageSets[0].isLatest).to.be.equal(true); - expect(channelState.messageSets[0].isCurrent).to.be.equal(true); - expect(channelState.messages.length).to.be.equal(10); - expect(channelState.messagePagination.hasPrev).to.be.equal(false); - - const previousHasNext = channelState.messagePagination.hasNext; - - channelState.pruneOldest(5); - - expect(channelState.messageSets.length).to.be.equal(1); - expect(channelState.messages.length).to.be.equal(5); - expect(channelState.messagePagination.hasPrev).to.be.equal(true); - expect(channelState.messagePagination.hasNext).to.be.equal(previousHasNext); - }); - - it('should do nothing if the current message set is not also the latest', () => { - expect(channelState.messageSets.length).to.be.equal(1); - - channelState.messageSets[0].isLatest = false; - - expect(channelState.messages.length).to.be.equal(10); - expect(channelState.messagePagination.hasPrev).to.be.equal(false); - - channelState.pruneOldest(5); - - expect(channelState.messages.length).to.be.equal(10); - expect(channelState.messagePagination.hasPrev).to.be.equal(false); - }); - - it('should prune the correct messageSet', () => { - channelState.addMessagesSorted( - Array.from({ length: 10 }, () => generateMsg({ date: toISOString(50) })), - false, - true, - true, - 'new', - ); - - expect(channelState.messageSets.length).to.be.equal(2); - - channelState.pruneOldest(5); - - const currentMessageSet = channelState.messageSets.find((ms) => ms.isCurrent); - const otherMessageSet = channelState.messageSets.find((ms) => !ms.isCurrent); - - expect(currentMessageSet.messages.length).to.be.equal(5); - expect(currentMessageSet.pagination.hasPrev).to.be.equal(true); - expect(channelState.messages).to.be.equal(currentMessageSet.messages); - - expect(otherMessageSet.messages.length).to.be.equal(10); - expect(otherMessageSet.pagination.hasPrev).to.be.equal(false); - }); - - it('should correctly apply pruning', () => { - channelState.pruneOldest(5); - - expect(channelState.messages.length).to.be.equal(5); - for (const message of initialMessages.slice(-5)) { - expect(channelState.messages.some((m) => m.id === message.id)).to.be.equal(true); - } - - for (const message of initialMessages.slice(0, 5)) { - expect(channelState.messages.some((m) => m.id === message.id)).to.be.equal(false); - } - }); }); describe('ChannelState reactions', () => { @@ -993,59 +92,6 @@ describe('ChannelState reactions', () => { state = new ChannelState(new Channel(client, 'live', 'stream', {})); state.addMessageSorted(message); }); - it('Add one reaction', () => { - const reaction = { - user_id: 'observer', - type: 'like', - score: 1, - }; - const msg = { ...message }; - msg.latest_reactions.push(reaction); - const newMessage = state.addReaction(reaction, msg); - expect(newMessage.own_reactions.length).to.be.eq(1); - // validate the message got updated in channel state - expect(state.messages[0].latest_reactions.length).to.be.eq(1); - }); - it('Add same reaction twice', () => { - let newMessage = state.addReaction( - { - user_id: 'observer', - type: 'like', - score: 1, - }, - message, - ); - newMessage = state.addReaction( - { - user_id: 'observer', - type: 'like', - score: 1, - }, - newMessage, - ); - expect(newMessage.own_reactions.length).to.be.eq(1); - }); - it('Add two reactions', () => { - let newMessage = state.addReaction( - { - user_id: 'observer', - type: 'like', - score: 1, - }, - message, - ); - newMessage = state.addReaction( - { - user_id: 'user2', - type: 'like', - score: 4, - }, - newMessage, - ); - expect(newMessage.own_reactions.length).to.be.eq(1); - expect(newMessage.own_reactions[0].user_id).to.be.eq('observer'); - }); - describe('_addReactionToState', () => { let addOwnReactionToMessageSpy; let reaction; @@ -1450,70 +496,106 @@ describe('deleteUserMessages', () => { state = new ChannelState(channel); }); - it('should remove content of messages from given user, when hardDelete is true', () => { + it('should remove content of pinned messages from given user, when hardDelete is true', () => { const user1 = generateUser(); const user2 = generateUser(); - const m1u1 = generateMsg({ user: user1 }); - const m2u1 = generateMsg({ user: user1 }); - const m1u2 = generateMsg({ user: user2 }); - const m2u2 = generateMsg({ user: user2 }); + const m1u1 = generateMsg({ + user: user1, + pinned: true, + pinned_at: new Date('2020-01-01T00:00:00.001Z'), + }); + const m2u1 = generateMsg({ + user: user1, + pinned: true, + pinned_at: new Date('2020-01-01T00:00:00.002Z'), + }); + const m1u2 = generateMsg({ + user: user2, + pinned: true, + pinned_at: new Date('2020-01-01T00:00:00.003Z'), + }); + const m2u2 = generateMsg({ + user: user2, + pinned: true, + pinned_at: new Date('2020-01-01T00:00:00.004Z'), + }); - state.addMessagesSorted([m1u1, m2u1, m1u2, m2u2]); + state.addPinnedMessages([m1u1, m2u1, m1u2, m2u2]); - expect(state.messages).to.have.length(4); + expect(state.pinnedMessages).to.have.length(4); state.deleteUserMessages(user1, true); - expect(state.messages).to.have.length(4); + const byId = (id) => state.pinnedMessages.find((m) => m.id === id); + + expect(state.pinnedMessages).to.have.length(4); - expect(state.messages[0].type).to.be.equal('deleted'); - expect(state.messages[0].text).to.be.equal(undefined); - expect(state.messages[0].html).to.be.equal(undefined); + expect(byId(m1u1.id).type).to.be.equal('deleted'); + expect(byId(m1u1.id).text).to.be.equal(undefined); + expect(byId(m1u1.id).html).to.be.equal(undefined); - expect(state.messages[1].type).to.be.equal('deleted'); - expect(state.messages[1].text).to.be.equal(undefined); - expect(state.messages[1].html).to.be.equal(undefined); + expect(byId(m2u1.id).type).to.be.equal('deleted'); + expect(byId(m2u1.id).text).to.be.equal(undefined); + expect(byId(m2u1.id).html).to.be.equal(undefined); - expect(state.messages[2].type).to.be.equal('regular'); - expect(state.messages[2].text).to.be.equal(m1u2.text); - expect(state.messages[2].html).to.be.equal(m1u2.html); + expect(byId(m1u2.id).type).to.be.equal('regular'); + expect(byId(m1u2.id).text).to.be.equal(m1u2.text); + expect(byId(m1u2.id).html).to.be.equal(m1u2.html); - expect(state.messages[3].type).to.be.equal('regular'); - expect(state.messages[3].text).to.be.equal(m2u2.text); - expect(state.messages[3].html).to.be.equal(m2u2.html); + expect(byId(m2u2.id).type).to.be.equal('regular'); + expect(byId(m2u2.id).text).to.be.equal(m2u2.text); + expect(byId(m2u2.id).html).to.be.equal(m2u2.html); }); - it('should mark messages from given user as deleted, when hardDelete is false', () => { + it('should mark pinned messages from given user as deleted, when hardDelete is false', () => { const user1 = generateUser(); const user2 = generateUser(); - const m1u1 = generateMsg({ user: user1 }); - const m2u1 = generateMsg({ user: user1 }); - const m1u2 = generateMsg({ user: user2 }); - const m2u2 = generateMsg({ user: user2 }); + const m1u1 = generateMsg({ + user: user1, + pinned: true, + pinned_at: new Date('2020-01-01T00:00:00.001Z'), + }); + const m2u1 = generateMsg({ + user: user1, + pinned: true, + pinned_at: new Date('2020-01-01T00:00:00.002Z'), + }); + const m1u2 = generateMsg({ + user: user2, + pinned: true, + pinned_at: new Date('2020-01-01T00:00:00.003Z'), + }); + const m2u2 = generateMsg({ + user: user2, + pinned: true, + pinned_at: new Date('2020-01-01T00:00:00.004Z'), + }); - state.addMessagesSorted([m1u1, m2u1, m1u2, m2u2]); - expect(state.messages).to.have.length(4); + state.addPinnedMessages([m1u1, m2u1, m1u2, m2u2]); + expect(state.pinnedMessages).to.have.length(4); state.deleteUserMessages(user1); - expect(state.messages).to.have.length(4); + const byId = (id) => state.pinnedMessages.find((m) => m.id === id); - expect(state.messages[0].type).to.be.equal('deleted'); - expect(state.messages[0].text).to.be.equal(m1u1.text); - expect(state.messages[0].html).to.be.equal(m1u1.html); + expect(state.pinnedMessages).to.have.length(4); - expect(state.messages[1].type).to.be.equal('deleted'); - expect(state.messages[1].text).to.be.equal(m2u1.text); - expect(state.messages[1].html).to.be.equal(m2u1.html); + expect(byId(m1u1.id).type).to.be.equal('deleted'); + expect(byId(m1u1.id).text).to.be.equal(m1u1.text); + expect(byId(m1u1.id).html).to.be.equal(m1u1.html); - expect(state.messages[2].type).to.be.equal('regular'); - expect(state.messages[2].text).to.be.equal(m1u2.text); - expect(state.messages[2].html).to.be.equal(m1u2.html); + expect(byId(m2u1.id).type).to.be.equal('deleted'); + expect(byId(m2u1.id).text).to.be.equal(m2u1.text); + expect(byId(m2u1.id).html).to.be.equal(m2u1.html); - expect(state.messages[3].type).to.be.equal('regular'); - expect(state.messages[3].text).to.be.equal(m2u2.text); - expect(state.messages[3].html).to.be.equal(m2u2.html); + expect(byId(m1u2.id).type).to.be.equal('regular'); + expect(byId(m1u2.id).text).to.be.equal(m1u2.text); + expect(byId(m1u2.id).html).to.be.equal(m1u2.html); + + expect(byId(m2u2.id).type).to.be.equal('regular'); + expect(byId(m2u2.id).text).to.be.equal(m2u2.text); + expect(byId(m2u2.id).html).to.be.equal(m2u2.html); }); }); @@ -1534,27 +616,6 @@ describe('deleteUserMessages — quoted_message regression (#1736)', () => { state = new ChannelState(channel); }); - it('does not throw when hard-deleting a message that quotes another message from the same user', () => { - const user1 = generateUser(); - const m1 = generateMsg({ user: user1 }); - const m2 = generateMsg({ - user: user1, - quoted_message: m1, - quoted_message_id: m1.id, - }); - - state.addMessagesSorted([m1, m2]); - - expect(() => state.deleteUserMessages(user1, true)).not.to.throw(); - - expect(state.messages).to.have.length(2); - expect(state.messages[0].type).to.be.equal('deleted'); - expect(state.messages[1].type).to.be.equal('deleted'); - // Hard-deleted parent retains the stripped shape (no quoted_message field). - expect(state.messages[1].text).to.be.equal(undefined); - expect(state.messages[1].quoted_message).to.be.equal(undefined); - }); - it('does not throw when hard-deleting a thread reply that quotes another same-user reply', () => { const user1 = generateUser(); const parent = generateMsg({ user: user1, id: 'parent-id' }); @@ -1612,54 +673,81 @@ describe('deleteUserMessages — quoted_message regression (#1736)', () => { expect(pinnedQuoter.quoted_message).to.be.equal(undefined); }); - it('soft-deletes a message that quotes another same-user message and marks the quoted_message as deleted', () => { + it('soft-deletes a thread reply that quotes another same-user reply and marks the quoted_message as deleted', () => { const user1 = generateUser(); - const m1 = generateMsg({ user: user1 }); - const m2 = generateMsg({ + const parent = generateMsg({ user: user1, id: 'parent-id' }); + const reply1 = generateMsg({ user: user1, - quoted_message: m1, - quoted_message_id: m1.id, + parent_id: parent.id, + date: '2020-01-01T00:00:01.000Z', + }); + const reply2 = generateMsg({ + user: user1, + parent_id: parent.id, + date: '2020-01-01T00:00:02.000Z', + quoted_message: reply1, + quoted_message_id: reply1.id, }); - state.addMessagesSorted([m1, m2]); + state.addMessagesSorted([parent, reply1, reply2]); expect(() => state.deleteUserMessages(user1, false)).not.to.throw(); - expect(state.messages[0].type).to.be.equal('deleted'); - expect(state.messages[1].type).to.be.equal('deleted'); + const thread = state.threads[parent.id]; + expect(thread[0].type).to.be.equal('deleted'); + expect(thread[1].type).to.be.equal('deleted'); // Soft-delete preserves message content via the spread path. - expect(state.messages[1].text).to.be.equal(m2.text); + expect(thread[1].text).to.be.equal(reply2.text); // quoted_message reference is replaced with a deleted placeholder. - expect(state.messages[1].quoted_message).to.not.be.equal(undefined); - expect(state.messages[1].quoted_message.id).to.be.equal(m1.id); - expect(state.messages[1].quoted_message.type).to.be.equal('deleted'); + expect(thread[1].quoted_message).to.not.be.equal(undefined); + expect(thread[1].quoted_message.id).to.be.equal(reply1.id); + expect(thread[1].quoted_message.type).to.be.equal('deleted'); }); - it('continues processing later messages after encountering a self-quote on hard-delete', () => { + it('continues processing later thread replies after encountering a self-quote on hard-delete', () => { const user1 = generateUser(); const user2 = generateUser(); - const mA = generateMsg({ user: user2, date: '2020-01-01T00:00:01.000Z' }); - const m1 = generateMsg({ user: user1, date: '2020-01-01T00:00:02.000Z' }); - const m2 = generateMsg({ + const parent = generateMsg({ user: user1, id: 'parent-id' }); + const rA = generateMsg({ + user: user2, + parent_id: parent.id, + date: '2020-01-01T00:00:01.000Z', + }); + const r1 = generateMsg({ user: user1, + parent_id: parent.id, + date: '2020-01-01T00:00:02.000Z', + }); + const r2 = generateMsg({ + user: user1, + parent_id: parent.id, date: '2020-01-01T00:00:03.000Z', - quoted_message: m1, - quoted_message_id: m1.id, + quoted_message: r1, + quoted_message_id: r1.id, + }); + const rB = generateMsg({ + user: user1, + parent_id: parent.id, + date: '2020-01-01T00:00:04.000Z', + }); + const rC = generateMsg({ + user: user2, + parent_id: parent.id, + date: '2020-01-01T00:00:05.000Z', }); - const mB = generateMsg({ user: user1, date: '2020-01-01T00:00:04.000Z' }); - const mC = generateMsg({ user: user2, date: '2020-01-01T00:00:05.000Z' }); - state.addMessagesSorted([mA, m1, m2, mB, mC]); + state.addMessagesSorted([parent, rA, r1, r2, rB, rC]); expect(() => state.deleteUserMessages(user1, true)).not.to.throw(); - const byId = (id) => state.messages.find((m) => m.id === id); - expect(byId(mA.id).type).to.be.equal('regular'); - expect(byId(m1.id).type).to.be.equal('deleted'); - expect(byId(m2.id).type).to.be.equal('deleted'); - // mB sits after the self-quote pair — previously the throw aborted the loop here. - expect(byId(mB.id).type).to.be.equal('deleted'); - expect(byId(mC.id).type).to.be.equal('regular'); + const thread = state.threads[parent.id]; + const byId = (id) => thread.find((m) => m.id === id); + expect(byId(rA.id).type).to.be.equal('regular'); + expect(byId(r1.id).type).to.be.equal('deleted'); + expect(byId(r2.id).type).to.be.equal('deleted'); + // rB sits after the self-quote pair — previously the throw aborted the loop here. + expect(byId(rB.id).type).to.be.equal('deleted'); + expect(byId(rC.id).type).to.be.equal('regular'); }); }); @@ -1674,18 +762,34 @@ describe('updateUserMessages', () => { state = new ChannelState(channel); }); - it('should update user property of messages from given user', () => { + it('should update user property of pinned messages from given user', () => { let user1 = generateUser(); const user2 = generateUser(); - const m1u1 = generateMsg({ user: user1 }); - const m2u1 = generateMsg({ user: user1 }); - const m1u2 = generateMsg({ user: user2 }); - const m2u2 = generateMsg({ user: user2 }); + const m1u1 = generateMsg({ + user: user1, + pinned: true, + pinned_at: new Date('2020-01-01T00:00:00.001Z'), + }); + const m2u1 = generateMsg({ + user: user1, + pinned: true, + pinned_at: new Date('2020-01-01T00:00:00.002Z'), + }); + const m1u2 = generateMsg({ + user: user2, + pinned: true, + pinned_at: new Date('2020-01-01T00:00:00.003Z'), + }); + const m2u2 = generateMsg({ + user: user2, + pinned: true, + pinned_at: new Date('2020-01-01T00:00:00.004Z'), + }); - state.addMessagesSorted([m1u1, m2u1, m1u2, m2u2]); + state.addPinnedMessages([m1u1, m2u1, m1u2, m2u2]); - expect(state.messages).to.have.length(4); + expect(state.pinnedMessages).to.have.length(4); const user1NewName = uuidv4(); user1 = { @@ -1693,93 +797,17 @@ describe('updateUserMessages', () => { name: user1NewName, }; - state.updateUserMessages(user1, true); + state.updateUserMessages(user1); - expect(state.messages).to.have.length(4); + const byId = (id) => state.pinnedMessages.find((m) => m.id === id); - expect(state.messages[0].user.name).to.be.equal(user1NewName); - expect(state.messages[1].user.name).to.be.equal(user1NewName); + expect(state.pinnedMessages).to.have.length(4); - expect(state.messages[2].user.name).to.be.equal(user2.name); - expect(state.messages[3].user.name).to.be.equal(user2.name); - }); -}); + expect(byId(m1u1.id).user.name).to.be.equal(user1NewName); + expect(byId(m2u1.id).user.name).to.be.equal(user1NewName); -describe('latestMessages', () => { - let state; - - beforeEach(() => { - const client = new StreamChat(); - client.userID = 'userId'; - const channel = new Channel(client, 'type', 'id', {}); - client._addChannelConfig({ cid: channel.cid, config: {} }); - state = new ChannelState(channel); - }); - - it('should return latest messages - if they are the current message set', () => { - const messages = [ - generateMsg({ id: '1' }), - generateMsg({ id: '2' }), - generateMsg({ id: '3' }), - ]; - state.addMessagesSorted(messages); - - expect(state.latestMessages.length).to.be.equal(messages.length); - expect(state.latestMessages[0].id).to.be.equal(messages[0].id); - expect(state.latestMessages[1].id).to.be.equal(messages[1].id); - expect(state.latestMessages[2].id).to.be.equal(messages[2].id); - }); - - it('should return latest messages - if they are not the current message set', () => { - const latestMessages = [ - generateMsg({ id: '2', date: toISOString(200) }), - generateMsg({ id: '3', date: toISOString(300) }), - generateMsg({ id: '4', date: toISOString(400) }), - ]; - state.addMessagesSorted(latestMessages); - const newMessages = [generateMsg({ id: '1', date: toISOString(100) })]; - state.addMessagesSorted(newMessages, false, true, true, 'new'); - state.messageSets[0].isCurrent = false; - state.messageSets[1].isCurrent = true; - - expect(state.latestMessages.length).to.be.equal(latestMessages.length); - expect(state.latestMessages[0].id).to.be.equal(latestMessages[0].id); - expect(state.latestMessages[1].id).to.be.equal(latestMessages[1].id); - expect(state.latestMessages[2].id).to.be.equal(latestMessages[2].id); - }); - - it('should return latest messages - if they are not the current message set and new messages received', () => { - const latestMessages = [ - generateMsg({ id: '2', date: toISOString(200) }), - generateMsg({ id: '3', date: toISOString(300) }), - generateMsg({ id: '4', date: toISOString(400) }), - ]; - state.addMessagesSorted(latestMessages); - const newMessages = [generateMsg({ id: '1', date: toISOString(100) })]; - state.addMessagesSorted(newMessages, false, true, true, 'new'); - state.messageSets[0].isCurrent = false; - state.messageSets[1].isCurrent = true; - const latestMessage = generateMsg({ id: '5', date: toISOString(500) }); - state.addMessagesSorted([latestMessage], false, true, true, 'latest'); - - expect(state.latestMessages.length).to.be.equal(latestMessages.length + 1); - expect(state.latestMessages[0].id).to.be.equal(latestMessages[0].id); - expect(state.latestMessages[1].id).to.be.equal(latestMessages[1].id); - expect(state.latestMessages[2].id).to.be.equal(latestMessages[2].id); - expect(state.latestMessages[3].id).to.be.equal(latestMessage.id); - }); -}); - -describe('messagePagination', () => { - it('is initiated with defaults', () => { - const state = new ChannelState(); - expect(state.messageSets[0].pagination).to.eql(DEFAULT_MESSAGE_SET_PAGINATION); - }); - it('is retrieved as default if not set', () => { - const state = new ChannelState(); - state.messageSets[0].pagination = undefined; - expect(state.messageSets[0].pagination).to.be.undefined; - expect(state.messagePagination).to.eql(DEFAULT_MESSAGE_SET_PAGINATION); + expect(byId(m1u2.id).user.name).to.be.equal(user2.name); + expect(byId(m2u2.id).user.name).to.be.equal(user2.name); }); }); @@ -2114,28 +1142,6 @@ describe('findMessage', () => { state = new ChannelState(channel); }); - it('message is in current message set', async () => { - const messageId = '8'; - state.addMessagesSorted( - [generateMsg({ id: messageId })], - false, - true, - true, - 'latest', - ); - state.addMessagesSorted([generateMsg({ id: '5' })], false, true, true, 'new'); - - expect(state.findMessage(messageId).id).to.eql(messageId); - }); - - it('message is in a different set', () => { - const messageId = '5'; - state.addMessagesSorted([generateMsg({ id: '8' })], false, true, true, 'latest'); - state.addMessagesSorted([generateMsg({ id: messageId })], false, true, true, 'new'); - - expect(state.findMessage(messageId).id).to.eql(messageId); - }); - it('message not found', async () => { state.addMessagesSorted([generateMsg({ id: '5' }), generateMsg({ id: '6' })]); diff --git a/test/unit/client.test.js b/test/unit/client.test.js index ed3307f30..58b7bf6ec 100644 --- a/test/unit/client.test.js +++ b/test/unit/client.test.js @@ -1087,7 +1087,7 @@ describe('StreamChat.queryChannels', async () => { postStub.restore(); }); - it('should not update pagination for queried message set', async () => { + it('seeds each queried channel paginator with its full message page', async () => { const client = await getClientWithUser(); const mockedChannelsQueryResponse = Array.from({ length: 10 }, () => ({ ...mockChannelQueryResponse, @@ -1097,19 +1097,20 @@ describe('StreamChat.queryChannels', async () => { ), })); const mock = sinon.mock(client); - mock.expects('post').returns(Promise.resolve(mockedChannelsQueryResponse)); + mock + .expects('post') + .returns(Promise.resolve({ channels: mockedChannelsQueryResponse })); await client.queryChannels(); + expect(Object.keys(client.activeChannels).length).to.be.greaterThan(0); Object.values(client.activeChannels).forEach((channel) => { - expect(channel.state.messageSets.length).to.be.equal(1); - expect(channel.state.messageSets[0].pagination).to.eql({ - hasNext: true, - hasPrev: true, - }); + expect(channel.messagePaginator.items).to.have.length( + DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE, + ); }); mock.restore(); }); - it('should update pagination for queried message set to prevent more pagination', async () => { + it('seeds each queried channel paginator with its partial message page', async () => { const client = await getClientWithUser(); const mockedChannelQueryResponse = Array.from({ length: 10 }, () => ({ ...mockChannelQueryResponse, @@ -1119,14 +1120,15 @@ describe('StreamChat.queryChannels', async () => { ), })); const mock = sinon.mock(client); - mock.expects('post').returns(Promise.resolve(mockedChannelQueryResponse)); + mock + .expects('post') + .returns(Promise.resolve({ channels: mockedChannelQueryResponse })); await client.queryChannels(); + expect(Object.keys(client.activeChannels).length).to.be.greaterThan(0); Object.values(client.activeChannels).forEach((channel) => { - expect(channel.state.messageSets.length).to.be.equal(1); - expect(channel.state.messageSets[0].pagination).to.eql({ - hasNext: true, - hasPrev: false, - }); + expect(channel.messagePaginator.items).to.have.length( + DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE - 1, + ); }); mock.restore(); }); @@ -1605,10 +1607,6 @@ describe('user.messages.deleted', () => { // thread replies channel.state.addMessagesSorted(thread1); - - expect(channel.state.messageSets).toHaveLength(2); - expect(channel.state.messageSets[0].messages).toHaveLength(messageSet1.length); - expect(channel.state.messageSets[1].messages).toHaveLength(messageSet2.length); expect(channel.state.pinnedMessages).toHaveLength(pinnedMessages.length); expect(channel.state.threads[parent_id]).toHaveLength(thread1.length); @@ -1629,15 +1627,9 @@ describe('user.messages.deleted', () => { client._handleClientEvent(event); channels.forEach((channel) => { - expect(channel.state.messageSets[0].messages).toHaveLength(messageSet1.length); - expect(channel.state.messageSets[1].messages).toHaveLength(messageSet2.length); - const check = (message) => { expect(message).toEqual(message); }; - - channel.state.messageSets[0].messages.forEach(check); - channel.state.messageSets[1].messages.forEach(check); channel.state.pinnedMessages.forEach(check); Object.values(channel.state.threads).forEach((replies) => replies.forEach(check)); }); @@ -1654,9 +1646,6 @@ describe('user.messages.deleted', () => { }; client._handleClientEvent(event); channels.forEach((channel) => { - expect(channel.state.messageSets[0].messages).toHaveLength(messageSet1.length); - expect(channel.state.messageSets[1].messages).toHaveLength(messageSet2.length); - const check = (message) => { const deletedMessage = { attachments: [], @@ -1692,9 +1681,6 @@ describe('user.messages.deleted', () => { expect(message).toEqual(message); } }; - - channel.state.messageSets[0].messages.forEach(check); - channel.state.messageSets[1].messages.forEach(check); channel.state.pinnedMessages.forEach(check); Object.values(channel.state.threads).forEach((replies) => replies.forEach(check)); }); @@ -1711,9 +1697,6 @@ describe('user.messages.deleted', () => { }; client._handleClientEvent(event); channels.forEach((channel) => { - expect(channel.state.messageSets[0].messages).toHaveLength(messageSet1.length); - expect(channel.state.messageSets[1].messages).toHaveLength(messageSet2.length); - const check = (message) => { if (message.user.id === bannedUser.id) { expect(message).toStrictEqual({ @@ -1736,9 +1719,6 @@ describe('user.messages.deleted', () => { expect(message).toEqual(message); } }; - - channel.state.messageSets[0].messages.forEach(check); - channel.state.messageSets[1].messages.forEach(check); channel.state.pinnedMessages.forEach(check); Object.values(channel.state.threads).forEach((replies) => replies.forEach(check)); }); @@ -1771,7 +1751,15 @@ describe('user.messages.deleted — quoted_message regression (#1736)', () => { quoted_message_id: m1.id, }); const channel = client.channel(type, id); + // addMessagesSorted registers the user->channel reference (client.state.userChannelReferences) + // that the client-level deletion loop walks; setItems puts the messages in the paginator + // (the message list source of truth) so the deletion has something to act on. channel.state.addMessagesSorted([m1, m2]); + channel.messagePaginator.setItems({ + valueOrFactory: [m1, m2], + isFirstPage: true, + isLastPage: true, + }); return { channel, m1, m2 }; }; @@ -1787,13 +1775,11 @@ describe('user.messages.deleted — quoted_message regression (#1736)', () => { expect(() => client._handleClientEvent(event)).not.toThrow(); - const messages = channel.state.messageSets[0].messages; - expect(messages).toHaveLength(2); - expect(messages.find((m) => m.id === m1.id).type).toBe('deleted'); - const quoter = messages.find((m) => m.id === m2.id); - expect(quoter.type).toBe('deleted'); - // Hard-delete strips the parent — no quoted_message field remains on it. - expect(quoter.quoted_message).toBeUndefined(); + // Both messages belong to the banned user, so a hard delete drops both from the + // active window; the point is that the self-quote (m2 -> m1) does not throw. + const items = channel.messagePaginator.items ?? []; + expect(items.find((m) => m.id === m1.id)).toBeUndefined(); + expect(items.find((m) => m.id === m2.id)).toBeUndefined(); }); it('still fires downstream client listeners after the self-quote encounter on hard-delete', () => { @@ -1825,9 +1811,9 @@ describe('user.messages.deleted — quoted_message regression (#1736)', () => { expect(() => client._handleClientEvent(event)).not.toThrow(); - const messages = channel.state.messageSets[0].messages; - expect(messages.find((m) => m.id === m1.id).type).toBe('deleted'); - expect(messages.find((m) => m.id === m2.id).type).toBe('deleted'); + const items = channel.messagePaginator.items ?? []; + expect(items.find((m) => m.id === m1.id)).toBeUndefined(); + expect(items.find((m) => m.id === m2.id)).toBeUndefined(); }); }); diff --git a/test/unit/utils.test.js b/test/unit/utils.test.js index b6faa4204..8878b8415 100644 --- a/test/unit/utils.test.js +++ b/test/unit/utils.test.js @@ -2,7 +2,6 @@ import { axiosParamsSerializer, binarySearchByDateEqualOrNearestGreater, formatMessage, - messageSetPagination, normalizeQuerySort, } from '../../src/utils'; import sinon from 'sinon'; @@ -145,3055 +144,6 @@ describe('reaction groups fallback', () => { }); }); -describe('messageSetPagination', () => { - const consoleErrorSpy = () => { - const _consoleError = console.error; - console.error = () => null; - return () => { - console.error = _consoleError; - }; - }; - const messages = [ - { created_at: '2024-08-05T08:55:00.199808Z', id: '0' }, - { created_at: '2024-08-05T08:55:01.199808Z', id: '1' }, - { created_at: '2024-08-05T08:55:02.199808Z', id: '2' }, - { created_at: '2024-08-05T08:55:03.199808Z', id: '3' }, - { created_at: '2024-08-05T08:55:04.199808Z', id: '4' }, - { created_at: '2024-08-05T08:55:05.199808Z', id: '5' }, - { created_at: '2024-08-05T08:55:06.199808Z', id: '6' }, - { created_at: '2024-08-05T08:55:07.199808Z', id: '7' }, - { created_at: '2024-08-05T08:55:08.199808Z', id: '8' }, - ]; - const shadowOlder = { - created_at: '2024-08-05T08:54:59.199808Z', - id: 'shadow-older', - }; - const shadowNewer = { - created_at: '2024-08-05T08:55:09.199808Z', - id: 'shadow-newer', - }; - - describe('linear', () => { - describe('returned page size size is 0', () => { - ['created_at_after_or_equal', 'created_at_after', 'id_gt', 'id_gte'].forEach( - (option) => { - it(`requested page size === returned page size === parent set size pagination with option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length, - returnedPage: [], - filteredReturnedPage: [], - parentSet: { messages: [], pagination: {} }, - }), - ).to.eql({ hasNext: false }); - }); - it(`requested page size === parent set size > returned page size with option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: 1, - returnedPage: [], - filteredReturnedPage: [], - parentSet: { messages: messages.slice(0, 1), pagination: {} }, - }), - ).to.eql({ hasNext: false }); - }); - it(`returned page size === parent set size pagination < requested page size with option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: 1, - returnedPage: [], - filteredReturnedPage: [], - parentSet: { messages: [], pagination: {} }, - }), - ).to.eql({ hasNext: false }); - }); - it(`requested page size === returned page size < parent set size pagination with option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length, - returnedPage: [], - filteredReturnedPage: [], - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasNext: false }); - }); - it(`returned page size < parent set size < requested page size pagination with option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: 1, - returnedPage: [], - filteredReturnedPage: [], - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasNext: false }); - }); - }, - ); - - [ - 'created_at_before_or_equal', - 'created_at_before', - 'id_lt', - 'id_lte', - undefined, - 'unrecognized', - ].forEach((option) => { - it(`requested page size === returned page size === parent set size pagination with option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length, - returnedPage: [], - filteredReturnedPage: [], - parentSet: { messages: [], pagination: {} }, - }), - ).to.eql({ hasPrev: false }); - }); - it(`requested page size === parent set size > returned page size with option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: 1, - returnedPage: [], - filteredReturnedPage: [], - parentSet: { messages: messages.slice(0, 1), pagination: {} }, - }), - ).to.eql({ hasPrev: false }); - }); - it(`returned page size === parent set size pagination < requested page size with option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: 1, - returnedPage: [], - filteredReturnedPage: [], - parentSet: { messages: [], pagination: {} }, - }), - ).to.eql({ hasPrev: false }); - }); - it(`requested page size === returned page size < parent set size pagination with option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length, - returnedPage: [], - filteredReturnedPage: [], - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: false }); - }); - it(`returned page size < parent set size < requested page size pagination with option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: 1, - returnedPage: [], - filteredReturnedPage: [], - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: false }); - }); - }); - }); - - ['created_at_after_or_equal', 'created_at_after', 'id_gt', 'id_gte'].forEach( - (option) => { - it(`requested page size === returned page size === parent set size pagination option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasNext: true }); - }); - - it(`returned page size === parent set size pagination < requested page size option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length + 1, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasNext: false }); - }); - - it(`returned page size === parent set size pagination > requested page size option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length - 1, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasNext: true }); - }); - - describe('first (oldest) page message matches the first parent set message', () => { - it(`requested page size === returned page size < parent set size pagination option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length - 1, - returnedPage: messages.slice(0, -1), - filteredReturnedPage: messages.slice(0, -1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({}); - }); - it(`requested page size === parent set size > returned page size option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length, - returnedPage: messages.slice(0, -1), - filteredReturnedPage: messages.slice(0, -1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({}); - }); - it(`requested page size < returned page size < parent set size pagination option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length - 2, - returnedPage: messages.slice(0, -1), - filteredReturnedPage: messages.slice(0, -1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({}); - }); - it(`requested page size < returned page size < parent set size pagination option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length - 3, - returnedPage: messages.slice(0, -2), - filteredReturnedPage: messages.slice(0, -2), - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({}); - }); - it(`returned page size < parent set size < requested page size pagination option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length, - returnedPage: messages.slice(0, -2), - filteredReturnedPage: messages.slice(0, -2), - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({}); - }); - it(`requested page size === returned page size > parent set size pagination option ${option}`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`parent set size < returned page size < requested page size pagination option ${option}`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length, - returnedPage: messages.slice(0, -1), - filteredReturnedPage: messages.slice(0, -1), - parentSet: { messages: messages.slice(0, -2), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`requested page size < parent set size < returned page size pagination option ${option}`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length - 2, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - }); - - describe('last page message matches the last parent set message', () => { - it(`requested page size === returned page size < parent set size pagination option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length - 1, - returnedPage: messages.slice(1), - filteredReturnedPage: messages.slice(1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasNext: true }); - }); - it(`requested page size === parent set size > returned page size option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length, - returnedPage: messages.slice(1), - filteredReturnedPage: messages.slice(1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasNext: false }); - }); - it(`requested page size < returned page size < parent set size pagination option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length - 2, - returnedPage: messages.slice(1), - filteredReturnedPage: messages.slice(1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasNext: true }); - }); - it(`returned page size < parent set size < requested page size pagination option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length, - returnedPage: messages.slice(2), - filteredReturnedPage: messages.slice(2), - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({ hasNext: false }); - }); - it(`requested page size === returned page size > parent set size pagination option ${option}`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(-1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`parent set size < returned page size < requested page size pagination option ${option}`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length, - returnedPage: messages.slice(1), - filteredReturnedPage: messages.slice(1), - parentSet: { messages: messages.slice(2), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`requested page size < parent set size < returned page size pagination option ${option}`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length - 2, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - }); - - describe('first page message & last page message do not match the first and last parent set messages', () => { - it(`requested page size === returned page size === parent set size pagination option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length, - returnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - filteredReturnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - parentSet: { messages, pagination: {} }, - }), - ).to.eql({}); - }); - it(`requested page size === parent set size > returned page size option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({}); - }); - it(`returned page size === parent set size pagination < requested page size option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length + 1, - returnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - filteredReturnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - parentSet: { messages, pagination: {} }, - }), - ).to.eql({}); - }); - - it(`returned page size === parent set size pagination > requested page size option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length - 1, - returnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - filteredReturnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - parentSet: { messages, pagination: {} }, - }), - ).to.eql({}); - }); - - it(`requested page size === returned page size < parent set size pagination option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length - 2, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({}); - }); - it(`requested page size < returned page size < parent set size pagination option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length - 3, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({}); - }); - it(`returned page size < parent set size < requested page size pagination option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length + 1, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({}); - }); - it(`requested page size === returned page size > parent set size pagination option ${option}`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(1, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`parent set size < returned page size < requested page size pagination option ${option}`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length, - returnedPage: messages.slice(1), - filteredReturnedPage: messages.slice(1), - parentSet: { messages: messages.slice(1, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`requested page size < parent set size < returned page size pagination option ${option}`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length - 3, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(1, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - }); - }, - ); - - [ - 'created_at_before_or_equal', - 'created_at_before', - 'id_lt', - 'id_lte', - undefined, - 'unrecognized', - ].forEach((option) => { - it(`requested page size === returned page size === parent set size pagination option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: true }); - }); - - it(`returned page size === parent set size pagination < requested page size option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length + 1, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: false }); - }); - - it(`returned page size === parent set size pagination > requested page size option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length - 1, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: true }); - }); - - describe('first (oldest) page message matches the first parent set message', () => { - it(`requested page size === returned page size < parent set size pagination option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length - 1, - returnedPage: messages.slice(0, -1), - filteredReturnedPage: messages.slice(0, -1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: true }); - }); - it(`requested page size < returned page size < parent set size pagination option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length - 2, - returnedPage: messages.slice(0, -1), - filteredReturnedPage: messages.slice(0, -1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: true }); - }); - it(`requested page size === parent set size > returned page size option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length, - returnedPage: messages.slice(0, -1), - filteredReturnedPage: messages.slice(0, -1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: false }); - }); - it(`requested page size < returned page size < parent set size pagination option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length - 3, - returnedPage: messages.slice(0, -2), - filteredReturnedPage: messages.slice(0, -2), - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({ hasPrev: true }); - }); - it(`returned page size < parent set size < requested page size pagination option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length, - returnedPage: messages.slice(0, -2), - filteredReturnedPage: messages.slice(0, -2), - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({ hasPrev: false }); - }); - it(`requested page size === returned page size > parent set size pagination option ${option}`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`parent set size < returned page size < requested page size pagination option ${option}`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length, - returnedPage: messages.slice(0, -1), - filteredReturnedPage: messages.slice(0, -1), - parentSet: { messages: messages.slice(0, -2), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`requested page size < parent set size < returned page size pagination option ${option}`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length - 2, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - }); - - describe('last page message matches the last parent set message', () => { - it(`requested page size === returned page size < parent set size pagination option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length - 1, - returnedPage: messages.slice(1), - filteredReturnedPage: messages.slice(1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({}); - }); - it(`requested page size === parent set size > returned page size option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length, - returnedPage: messages.slice(1), - filteredReturnedPage: messages.slice(1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({}); - }); - it(`requested page size < returned page size < parent set size pagination option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length - 2, - returnedPage: messages.slice(1), - filteredReturnedPage: messages.slice(1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({}); - }); - it(`returned page size < parent set size < requested page size pagination option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length, - returnedPage: messages.slice(2), - filteredReturnedPage: messages.slice(2), - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({}); - }); - it(`requested page size === returned page size > parent set size pagination option ${option}`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(-1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`parent set size < returned page size < requested page size pagination option ${option}`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length, - returnedPage: messages.slice(1), - filteredReturnedPage: messages.slice(1), - parentSet: { messages: messages.slice(2), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`requested page size < parent set size < returned page size pagination option ${option}`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length - 2, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - }); - - describe('first page message & last page message do not match the first and last parent set messages', () => { - it(`requested page size === returned page size === parent set size pagination option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length, - returnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - filteredReturnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - parentSet: { messages, pagination: {} }, - }), - ).to.eql({}); - }); - it(`requested page size === parent set size > returned page size option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({}); - }); - it(`returned page size === parent set size pagination < requested page size option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length + 1, - returnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - filteredReturnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - parentSet: { messages, pagination: {} }, - }), - ).to.eql({}); - }); - - it(`returned page size === parent set size pagination > requested page size option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length - 1, - returnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - filteredReturnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - parentSet: { messages, pagination: {} }, - }), - ).to.eql({}); - }); - - it(`requested page size === returned page size < parent set size pagination option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length - 2, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({}); - }); - it(`requested page size < returned page size < parent set size pagination option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length - 3, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({}); - }); - it(`returned page size < parent set size < requested page size pagination option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length + 1, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({}); - }); - it(`requested page size === returned page size > parent set size pagination option ${option}`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(1, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`parent set size < returned page size < requested page size pagination option ${option}`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length, - returnedPage: messages.slice(1), - filteredReturnedPage: messages.slice(1), - parentSet: { messages: messages.slice(1, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`requested page size < parent set size < returned page size pagination option ${option}`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length - 3, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(1, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - }); - }); - - describe('with filtered first or last returned messages', () => { - it('sets hasPrev when oldest returned message is filtered but raw page is full (id_lt)', () => { - const filtered = messages.slice(0, 8); - expect( - messageSetPagination({ - messagePaginationOptions: { id_lt: 'X' }, - requestedPageSize: 9, - returnedPage: [shadowOlder, ...filtered], - filteredReturnedPage: filtered, - parentSet: { messages: filtered, pagination: {} }, - }), - ).to.eql({ hasPrev: true }); - }); - - it('sets hasPrev false when oldest returned message is filtered and raw page is not full (id_lt)', () => { - const filtered = messages.slice(0, 4); - expect( - messageSetPagination({ - messagePaginationOptions: { id_lt: 'X' }, - requestedPageSize: 9, - returnedPage: [shadowOlder, ...filtered], - filteredReturnedPage: filtered, - parentSet: { messages: filtered, pagination: {} }, - }), - ).to.eql({ hasPrev: false }); - }); - - it('sets hasNext when newest returned message is filtered but raw page is full (id_gt)', () => { - const filtered = messages.slice(1, 9); - expect( - messageSetPagination({ - messagePaginationOptions: { id_gt: 'X' }, - requestedPageSize: 9, - returnedPage: [...filtered, shadowNewer], - filteredReturnedPage: filtered, - parentSet: { messages: filtered, pagination: {} }, - }), - ).to.eql({ hasNext: true }); - }); - - it('sets hasNext false when newest returned message is filtered and raw page is not full (id_gt)', () => { - const filtered = messages.slice(4, 9); - expect( - messageSetPagination({ - messagePaginationOptions: { id_gt: 'X' }, - requestedPageSize: 9, - returnedPage: [...filtered, shadowNewer], - filteredReturnedPage: filtered, - parentSet: { messages: filtered, pagination: {} }, - }), - ).to.eql({ hasNext: false }); - }); - }); - }); - - describe('jumping to a message', () => { - const oddSizeReturnPage = messages; - const evenSizeReturnPage = messages.slice(0, -1); - const createdAtISOString = (index, msgs) => - new Date(new Date(msgs[index].created_at).getTime() - 500).toISOString(); - - [ - { - description: 'odd return page size', - messages: oddSizeReturnPage, - messagePaginationOptions: { - firstHalf: { created_at_around: createdAtISOString(2, oddSizeReturnPage) }, - mid: { created_at_around: createdAtISOString(4, oddSizeReturnPage) }, - secondHalf: { created_at_around: createdAtISOString(6, oddSizeReturnPage) }, - }, - option: 'created_at_around', - }, - { - description: 'even return page size', - messages: evenSizeReturnPage, - messagePaginationOptions: { - firstHalf: { created_at_around: createdAtISOString(2, evenSizeReturnPage) }, - mid: { created_at_around: createdAtISOString(4, evenSizeReturnPage) }, - secondHalf: { created_at_around: createdAtISOString(5, evenSizeReturnPage) }, - }, - option: 'created_at_around', - }, - { - description: 'odd return page size', - messages: oddSizeReturnPage, - messagePaginationOptions: { - firstHalf: { id_around: oddSizeReturnPage[2].id }, - mid: { id_around: oddSizeReturnPage[4].id }, - secondHalf: { id_around: oddSizeReturnPage[6].id }, - }, - option: 'id_around', - }, - { - description: 'even return page size', - messages: evenSizeReturnPage, - messagePaginationOptions: { - firstHalf: { id_around: evenSizeReturnPage[2].id }, - mid: { id_around: evenSizeReturnPage[4].id }, - secondHalf: { id_around: evenSizeReturnPage[5].id }, - }, - option: 'id_around', - }, - ].forEach(({ description, messagePaginationOptions, messages, option }) => { - describe(description, () => { - describe(`with ${option}`, () => { - describe('the target msg is in the first page half', () => { - it(`requested page size === returned page size === parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.firstHalf, - requestedPageSize: messages.length, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: true }); - }); - - it(`returned page size === parent set size pagination < requested page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.firstHalf, - requestedPageSize: messages.length + 1, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - - it(`returned page size === parent set size pagination > requested page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.firstHalf, - requestedPageSize: messages.length - 1, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: true }); - }); - - describe('first (oldest) page message matches the first parent set message', () => { - it(`requested page size === parent set size > returned page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.firstHalf, - requestedPageSize: messages.length - 1, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - it(`requested page size === returned page size < parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.firstHalf, - requestedPageSize: messages.length - 2, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({ hasPrev: false }); - }); - it(`requested page size < returned page size < parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.firstHalf, - requestedPageSize: messages.length - 3, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({ hasPrev: false }); - }); - it(`returned page size < parent set size < requested page size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.firstHalf, - requestedPageSize: messages.length, - returnedPage: messages.slice(0, -2), - filteredReturnedPage: messages.slice(0, -2), - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - it(`requested page size === returned page size > parent set size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.firstHalf, - requestedPageSize: messages.length, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`parent set size < returned page size < requested page size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.firstHalf, - requestedPageSize: messages.length, - returnedPage: messages.slice(0, -2), - filteredReturnedPage: messages.slice(0, -2), - parentSet: { messages: messages.slice(0, -3), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`requested page size < parent set size < returned page size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.firstHalf, - requestedPageSize: messages.length - 2, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - }); - - describe('last page message matches the last parent set message', () => { - it(`requested page size === parent set size > returned page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.firstHalf, - requestedPageSize: messages.length - 1, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - it(`requested page size === returned page size < parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.firstHalf, - requestedPageSize: messages.length - 2, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({ hasPrev: false }); - }); - it(`requested page size < returned page size < parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.firstHalf, - requestedPageSize: messages.length - 3, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({ hasPrev: false }); - }); - it(`returned page size < parent set size < requested page size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.firstHalf, - requestedPageSize: messages.length, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - it(`requested page size === returned page size > parent set size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.firstHalf, - requestedPageSize: messages.length, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`parent set size < returned page size < requested page size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.firstHalf, - requestedPageSize: messages.length, - returnedPage: messages.slice(2), - filteredReturnedPage: messages.slice(2), - parentSet: { messages: messages.slice(3), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`requested page size < parent set size < returned page size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.firstHalf, - requestedPageSize: messages.length - 2, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - }); - - describe('first page message & last page message do not match the first and last parent set messages', () => { - it(`requested page size === returned page size === parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.firstHalf, - requestedPageSize: messages.length, - returnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - filteredReturnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - parentSet: { messages, pagination: {} }, - }), - ).to.eql({}); - }); - it(`requested page size === parent set size > returned page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.firstHalf, - requestedPageSize: messages.length - 1, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - it(`returned page size === parent set size pagination < requested page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.firstHalf, - requestedPageSize: messages.length, - returnedPage: [ - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - ], - filteredReturnedPage: [ - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - ], - parentSet: { messages: messages.slice(1, -1), pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - it(`returned page size === parent set size pagination > requested page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.firstHalf, - requestedPageSize: messages.length - 1, - returnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - filteredReturnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - parentSet: { messages, pagination: {} }, - }), - ).to.eql({}); - }); - it(`requested page size === returned page size < parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.firstHalf, - requestedPageSize: messages.length, - returnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - filteredReturnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - parentSet: { messages, pagination: {} }, - }), - ).to.eql({}); - }); - it(`requested page size < returned page size < parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.firstHalf, - requestedPageSize: messages.length - 3, - returnedPage: [ - messages[0], - ...messages.slice(2, -2), - messages.slice(-2, -1)[0], - ], - filteredReturnedPage: [ - messages[0], - ...messages.slice(2, -2), - messages.slice(-2, -1)[0], - ], - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({}); - }); - it(`returned page size < parent set size < requested page size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.firstHalf, - requestedPageSize: messages.length + 1, - returnedPage: [ - messages[1], - ...messages.slice(2, -2), - messages.slice(-1)[0], - ], - filteredReturnedPage: [ - messages[1], - ...messages.slice(2, -2), - messages.slice(-1)[0], - ], - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - it(`requested page size === returned page size > parent set size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.firstHalf, - requestedPageSize: messages.length, - returnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - filteredReturnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - parentSet: { messages: messages.slice(1, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`parent set size < returned page size < requested page size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.firstHalf, - requestedPageSize: messages.length, - returnedPage: [ - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - ], - filteredReturnedPage: [ - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - ], - parentSet: { messages: messages.slice(1, -2), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`requested page size < parent set size < returned page size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.firstHalf, - requestedPageSize: messages.length - 2, - returnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - filteredReturnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - }); - }); - - describe('the target msg is in the middle of the page', () => { - it(`requested page size === returned page size === parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.mid, - requestedPageSize: messages.length, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: true, hasNext: true }); - }); - - it(`returned page size === parent set size pagination < requested page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.mid, - requestedPageSize: messages.length + 1, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - - it(`returned page size === parent set size pagination > requested page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.mid, - requestedPageSize: messages.length - 1, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: true, hasNext: true }); - }); - - describe('first (oldest) page message matches the first parent set message', () => { - it(`requested page size === parent set size > returned page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.mid, - requestedPageSize: messages.length - 1, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - it(`requested page size === returned page size < parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.mid, - requestedPageSize: messages.length - 2, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({ hasPrev: true }); - }); - it(`requested page size < returned page size < parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.mid, - requestedPageSize: messages.length - 3, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({ hasPrev: true }); - }); - it(`returned page size < parent set size < requested page size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.mid, - requestedPageSize: messages.length, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - it(`requested page size === returned page size > parent set size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.mid, - requestedPageSize: messages.length, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`parent set size < returned page size < requested page size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.mid, - requestedPageSize: messages.length, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(1, -2), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`requested page size < parent set size < returned page size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.mid, - requestedPageSize: messages.length - 2, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - }); - - describe('last page message matches the last parent set message', () => { - it(`requested page size === parent set size > returned page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.mid, - requestedPageSize: messages.length - 1, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - it(`requested page size === returned page size < parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.mid, - requestedPageSize: messages.length - 2, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({ hasNext: true }); - }); - it(`requested page size < returned page size < parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.mid, - requestedPageSize: messages.length - 3, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({ hasNext: true }); - }); - it(`returned page size < parent set size < requested page size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.mid, - requestedPageSize: messages.length, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - it(`requested page size === returned page size > parent set size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.mid, - requestedPageSize: messages.length, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`parent set size < returned page size < requested page size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.mid, - requestedPageSize: messages.length, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(2, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`requested page size < parent set size < returned page size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.mid, - requestedPageSize: messages.length - 2, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - }); - - describe('first page message & last page message do not match the first and last parent set messages', () => { - it(`requested page size === returned page size === parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.mid, - requestedPageSize: messages.length, - returnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - filteredReturnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - parentSet: { messages, pagination: {} }, - }), - ).to.eql({}); - }); - it(`requested page size === parent set size > returned page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.mid, - requestedPageSize: messages.length, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - it(`returned page size === parent set size pagination < requested page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.mid, - requestedPageSize: messages.length + 1, - returnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - filteredReturnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - it(`returned page size === parent set size pagination > requested page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.mid, - requestedPageSize: messages.length - 1, - returnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - filteredReturnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - parentSet: { messages, pagination: {} }, - }), - ).to.eql({}); - }); - it(`requested page size === returned page size < parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.mid, - requestedPageSize: messages.length - 2, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({}); - }); - it(`requested page size < returned page size < parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.mid, - requestedPageSize: messages.length - 3, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({}); - }); - it(`returned page size < parent set size < requested page size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.mid, - requestedPageSize: messages.length + 1, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - it(`requested page size === returned page size > parent set size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.mid, - requestedPageSize: messages.length, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(1, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`parent set size < returned page size < requested page size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.mid, - requestedPageSize: messages.length + 1, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(1, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`requested page size < parent set size < returned page size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.mid, - requestedPageSize: messages.length - 3, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(1, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - }); - }); - - describe('the target msg is in the second page half', () => { - it(`requested page size === returned page size === parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.secondHalf, - requestedPageSize: messages.length, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: true, hasNext: false }); - }); - - it(`returned page size === parent set size pagination < requested page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.secondHalf, - requestedPageSize: messages.length + 1, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - - it(`returned page size === parent set size pagination > requested page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.secondHalf, - requestedPageSize: messages.length - 1, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: true, hasNext: false }); - }); - - describe('first (oldest) page message matches the first parent set message', () => { - it(`requested page size === parent set size > returned page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.secondHalf, - requestedPageSize: messages.length - 1, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - it(`requested page size === returned page size < parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.secondHalf, - requestedPageSize: messages.length - 2, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({ hasPrev: true }); - }); - it(`requested page size < returned page size < parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.secondHalf, - requestedPageSize: messages.length - 3, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({ hasPrev: true }); - }); - it(`returned page size < parent set size < requested page size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.secondHalf, - requestedPageSize: messages.length, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - it(`requested page size === returned page size > parent set size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.secondHalf, - requestedPageSize: messages.length, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`parent set size < returned page size < requested page size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.secondHalf, - requestedPageSize: messages.length, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(1, -2), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`requested page size < parent set size < returned page size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.secondHalf, - requestedPageSize: messages.length - 2, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - }); - - describe('last page message matches the last parent set message', () => { - it(`requested page size === parent set size > returned page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.secondHalf, - requestedPageSize: messages.length - 1, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - it(`requested page size === returned page size < parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.secondHalf, - requestedPageSize: messages.length - 2, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({ hasNext: false }); - }); - it(`requested page size < returned page size < parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.secondHalf, - requestedPageSize: messages.length - 3, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({ hasNext: false }); - }); - it(`returned page size < parent set size < requested page size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.secondHalf, - requestedPageSize: messages.length, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - it(`requested page size === returned page size > parent set size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.secondHalf, - requestedPageSize: messages.length, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`parent set size < returned page size < requested page size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.secondHalf, - requestedPageSize: messages.length, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(2, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`requested page size < parent set size < returned page size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.secondHalf, - requestedPageSize: messages.length - 2, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - }); - - describe('first page message & last page message do not match the first and last parent set messages', () => { - it(`requested page size === returned page size === parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.secondHalf, - requestedPageSize: messages.length, - returnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - filteredReturnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - parentSet: { messages, pagination: {} }, - }), - ).to.eql({}); - }); - it(`requested page size === parent set size > returned page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.secondHalf, - requestedPageSize: messages.length, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - it(`returned page size === parent set size pagination < requested page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.secondHalf, - requestedPageSize: messages.length + 1, - returnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - filteredReturnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - it(`returned page size === parent set size pagination > requested page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.secondHalf, - requestedPageSize: messages.length - 1, - returnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - filteredReturnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - parentSet: { messages, pagination: {} }, - }), - ).to.eql({}); - }); - it(`requested page size === returned page size < parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.secondHalf, - requestedPageSize: messages.length - 2, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({}); - }); - it(`requested page size < returned page size < parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.secondHalf, - requestedPageSize: messages.length - 3, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({}); - }); - it(`returned page size < parent set size < requested page size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.secondHalf, - requestedPageSize: messages.length + 1, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - it(`requested page size === returned page size > parent set size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.secondHalf, - requestedPageSize: messages.length, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(1, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`parent set size < returned page size < requested page size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.secondHalf, - requestedPageSize: messages.length + 1, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(1, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`requested page size < parent set size < returned page size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.secondHalf, - requestedPageSize: messages.length - 3, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(1, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - }); - }); - }); - - describe('with created_at_around', () => { - describe('the target msg created_at < the earliest parent set message creation date', () => { - const created_at_around = '2000-08-05T08:55:00.199808Z'; - - it(`requested page size === returned page size === parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: false }); - }); - - it(`returned page size === parent set size pagination < requested page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length + 1, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - - it(`returned page size === parent set size pagination > requested page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length - 1, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: false }); - }); - - describe('first (oldest) page message matches the first parent set message', () => { - it(`requested page size === parent set size > returned page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length - 1, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({ hasPrev: false }); - }); - it(`requested page size === returned page size < parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length - 2, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({ hasPrev: false }); - }); - it(`requested page size < returned page size < parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length - 3, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({ hasPrev: false }); - }); - it(`returned page size < parent set size < requested page size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - it(`requested page size === returned page size > parent set size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`parent set size < returned page size < requested page size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(1, -2), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`requested page size < parent set size < returned page size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length - 2, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - }); - - describe('last page message matches the last parent set message', () => { - it(`requested page size === parent set size > returned page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length - 1, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({ hasPrev: false }); - }); - it(`requested page size === returned page size < parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length - 2, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({ hasPrev: false }); - }); - it(`requested page size < returned page size < parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length - 3, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({ hasPrev: false }); - }); - it(`returned page size < parent set size < requested page size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - it(`requested page size === returned page size > parent set size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`parent set size < returned page size < requested page size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(2, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`requested page size < parent set size < returned page size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length - 2, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - }); - - describe('first page message & last page message do not match the first and last parent set messages', () => { - it(`requested page size === returned page size === parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length, - returnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - filteredReturnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: false }); - }); - it(`requested page size === parent set size > returned page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: false }); - }); - it(`returned page size === parent set size pagination < requested page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length + 1, - returnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - filteredReturnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - it(`returned page size === parent set size pagination > requested page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length - 1, - returnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - filteredReturnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: false }); - }); - it(`requested page size === returned page size < parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length - 2, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: false }); - }); - it(`requested page size < returned page size < parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length - 3, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: false }); - }); - it(`returned page size < parent set size < requested page size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length + 1, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - it(`requested page size === returned page size > parent set size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(1, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`parent set size < returned page size < requested page size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length + 1, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(1, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`requested page size < parent set size < returned page size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length - 3, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(1, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - }); - }); - - describe('the target msg created_at > the latest parent set message creation date', () => { - const created_at_around = '3000-08-05T08:55:00.199808Z'; - - it(`requested page size === returned page size === parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasNext: false }); - }); - - it(`returned page size === parent set size pagination < requested page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length + 1, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - - it(`returned page size === parent set size pagination > requested page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length - 1, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasNext: false }); - }); - - describe('first (oldest) page message matches the first parent set message', () => { - it(`requested page size === parent set size > returned page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length - 1, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({ hasNext: false }); - }); - it(`requested page size === returned page size < parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length - 2, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({ hasNext: false }); - }); - it(`requested page size < returned page size < parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length - 3, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({ hasNext: false }); - }); - it(`returned page size < parent set size < requested page size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - it(`requested page size === returned page size > parent set size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`parent set size < returned page size < requested page size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(1, -2), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`requested page size < parent set size < returned page size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length - 2, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - }); - - describe('last page message matches the last parent set message', () => { - it(`requested page size === parent set size > returned page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length - 1, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({ hasNext: false }); - }); - it(`requested page size === returned page size < parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length - 2, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({ hasNext: false }); - }); - it(`requested page size < returned page size < parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length - 3, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({ hasNext: false }); - }); - it(`returned page size < parent set size < requested page size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - it(`requested page size === returned page size > parent set size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`parent set size < returned page size < requested page size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(2, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`requested page size < parent set size < returned page size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length - 2, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - }); - - describe('first page message & last page message do not match the first and last parent set messages', () => { - it(`requested page size === returned page size === parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length, - returnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - filteredReturnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasNext: false }); - }); - it(`requested page size === parent set size > returned page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasNext: false }); - }); - it(`returned page size === parent set size pagination < requested page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length + 1, - returnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - filteredReturnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - it(`returned page size === parent set size pagination > requested page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length - 1, - returnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - filteredReturnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasNext: false }); - }); - it(`requested page size === returned page size < parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length - 2, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasNext: false }); - }); - it(`requested page size < returned page size < parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length - 3, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasNext: false }); - }); - it(`returned page size < parent set size < requested page size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length + 1, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - it(`requested page size === returned page size > parent set size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(1, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`parent set size < returned page size < requested page size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length + 1, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(1, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`requested page size < parent set size < returned page size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length - 3, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(1, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - }); - }); - }); - }); - }); - - [ - { - description: '0 return page size', - messages: [], - messagePaginationOptions: { - created_at_around: createdAtISOString(2, oddSizeReturnPage), - }, - option: 'created_at_around', - }, - { - description: '0 return page size', - messages: [], - messagePaginationOptions: { id_around: oddSizeReturnPage[4].id }, - option: 'id_around', - }, - ].forEach(({ description, messagePaginationOptions, messages, option }) => { - describe(description, () => { - describe(`with ${option}`, () => { - it(`requested page size === returned page size === parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions, - requestedPageSize: messages.length, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages, pagination: {} }, - }), - ).to.eql({}); - }); - it(`requested page size === parent set size > returned page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions, - requestedPageSize: 1, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: evenSizeReturnPage.slice(0, 1), pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - it(`returned page size === parent set size pagination < requested page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions, - requestedPageSize: 1, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - it(`requested page size === returned page size < parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions, - requestedPageSize: messages.length, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: evenSizeReturnPage, pagination: {} }, - }), - ).to.eql({}); - }); - it(`returned page size < parent set size < requested page size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions, - requestedPageSize: 1, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: evenSizeReturnPage, pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - }); - }); - }); - - describe('with filtered first or last returned messages', () => { - const createdAtAroundMidMessage4 = new Date( - new Date(messages[4].created_at).getTime() - 500, - ).toISOString(); - - it('id_around: sets hasPrev and hasNext when oldest returned row is filtered out', () => { - expect( - messageSetPagination({ - messagePaginationOptions: { id_around: messages[4].id }, - requestedPageSize: messages.length, - returnedPage: [shadowOlder, ...messages], - filteredReturnedPage: messages, - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: true, hasNext: true }); - }); - - it('id_around: sets hasPrev false when newest returned row is filtered out (target in first half)', () => { - expect( - messageSetPagination({ - messagePaginationOptions: { id_around: messages[2].id }, - requestedPageSize: messages.length, - returnedPage: [...messages, shadowNewer], - filteredReturnedPage: messages, - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: true }); - }); - - it('created_at_around: sets hasPrev and hasNext when oldest returned row is filtered out', () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around: createdAtAroundMidMessage4 }, - requestedPageSize: messages.length, - returnedPage: [shadowOlder, ...messages], - filteredReturnedPage: messages, - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: true, hasNext: true }); - }); - - it('created_at_around: sets hasPrev false and hasNext true when newest returned row is filtered out', () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around: createdAtAroundMidMessage4 }, - requestedPageSize: messages.length, - returnedPage: [...messages, shadowNewer], - filteredReturnedPage: messages, - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: true }); - }); - }); - }); -}); - describe('binarySearchByDateEqualOrNearestGreater', () => { const messages = [ { created_at: '2024-08-05T08:55:00.199808Z', id: '0' }, From d04ddee34af9a60fcaa312c587091a926708ff37 Mon Sep 17 00:00:00 2001 From: martincupela Date: Mon, 20 Jul 2026 14:36:18 +0200 Subject: [PATCH 04/25] docs(spec): record Task 11 step 3a completion and 3b plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split the remaining ChannelState message-storage removal into 3a (main message list, threads retained — DONE, both suites green) and 3b (threads + full method delete — next). Co-Authored-By: Claude Opus 4.8 --- .../plan.md | 43 +++++++++++++------ 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/specs/remove-legacy-channelstate-messages/plan.md b/specs/remove-legacy-channelstate-messages/plan.md index 9d4e463b9..70d28d089 100644 --- a/specs/remove-legacy-channelstate-messages/plan.md +++ b/specs/remove-legacy-channelstate-messages/plan.md @@ -280,7 +280,9 @@ dual-writing → delete the store → types/exports → tests. **Dependencies:** Task 9, Task 10 (and Task 15 if offline in scope) -**Status:** in progress (sub-steps 1-2 done; step sequence below in progress) +**Status:** in progress — reaction sub-steps 1-2 done; **step 3a DONE** (main message-list +storage removed, threads retained, both suites green); **step 3b NEXT** (remove +`channel.state.threads` + fully delete the shared methods + re-home thread reply `own_reactions`) **Owner:** claude @@ -316,17 +318,34 @@ paginators (owner-approved). preservation must move onto `Thread.state.replies` — done in step 4/5 together with removing `channel.state.threads` (when the channel stops enriching reply events). A paginator-level `reflectReaction` on the Thread would not fix the `state.replies`-based UI. -3. Delete the message-list/thread methods: `addMessagesSorted`/`addMessageSorted`, `removeMessage`, - `deleteUserMessages`, `findMessage`, `addReaction`/`removeReaction`; shrink `_updateMessage` / - `_updateQuotedMessageReferences` to pinned-only. -4. Remove writes/callers: `message.new/updated/deleted/channel.truncated/channel.hidden` handlers, - `getReplies` (channel.ts) thread population, `_initializeState` split (keep read/members/watchers/pinned), - `client._deleteUserMessageReference`, `offline_support_api`. Re-home `last_message_at`. -5. Delete storage: `messageSets` / `messages` / `latestMessages` / `threads` + geometry helpers + - `utils.messageSetPagination`. -6. Test surgery: remove/rewrite the message-set machinery + thread `ChannelState` tests (both repos); - keep pinned/reaction-merge coverage; add `reflectReaction` tests. -7. Verify JS + React suites green; `yarn types` + `yarn lint` clean in both repos. + The remaining removal is split into **3a** (main message list, threads retained) and **3b** + (threads) so each lands with a green suite. + +3. **[DONE] Step 3a — remove the main message-list storage; retain `channel.state.threads`.** + - Removed `messageSets` / `messages` / `latestMessages` accessors + all pure message-set methods and + geometry helpers; `addMessagesSorted`/`addMessageSorted` shrunk to shadow-skip + format + + `updateUserReference` + stale-thread-cleanup + `last_message_at` + thread population. + `removeMessage`/`findMessage`/`_updateQuotedMessageReferences` are thread-only; `_updateMessage`, + `deleteUserMessages`, `updateUserMessages` are thread+pinned; `clearMessages` clears pinned only. + - Removed `utils.messageSetPagination` (+ helpers/type/unused imports). + - Rewired writers/callers: `message.new/updated/deleted`, `channel.truncated`, `channel.hidden`, + `_initializeState` (no set param/return), `query`/`queryChannels` seeding via `seedFirstPageSync`, + `client._deleteUserMessageReference`. Channel + client reaction/deletion handlers act on + `channel.messagePaginator` (`reflectReaction` / `applyMessageDeletionForUser`). + - Full test surgery in both repos: deleted message-set machinery specs, re-scoped surviving + deleteUser/updateUser + #1736 self-quote coverage to threads + pinned, migrated event/query tests + to the paginator. JS 2605 pass, React 2517 pass; `yarn types` + `yarn lint` clean in both. + - Commits: JS `caba066b` (src) + `c238323f` (tests); React `a61e634d4` (readers) + `79e26c037` (test). +4. **[NEXT] Step 3b — remove `channel.state.threads` + fully delete the shared methods.** + - Delete `addMessagesSorted`/`addMessageSorted`, `removeMessage`, `deleteUserMessages`, `findMessage`, + `addReaction`/`removeReaction`, `updateUserMessages`; shrink `_updateMessage` / + `_updateQuotedMessageReferences` to pinned-only or delete. + - Re-home thread reply `own_reactions` preservation onto `Thread.state.replies` (channel stops + enriching reply events) — the reaction sub-step-2 caveat above. + - Remove `getReplies` (channel.ts) thread population and any remaining `channel.state.threads` readers; + `offline_support_api`. Then the full-delete of `addMessagesSorted` (its last caller was the user→channel + reference registration — relocate that). + - Test surgery: remove the thread `ChannelState` tests; verify both suites green + types/lint clean. **Acceptance Criteria:** From b9c4ed61a9dcf81f608eb9007c46b3b6bd7a4899 Mon Sep 17 00:00:00 2001 From: martincupela Date: Mon, 20 Jul 2026 15:51:02 +0200 Subject: [PATCH 05/25] refactor(channel_state): remove channel.state.threads, re-home reply state to Thread Delete the ChannelState thread shadow and its thread-only methods (removeMessage, findMessage, _updateQuotedMessageReferences, _addReactionToState/_removeReactionFromState); scope _updateMessage, updateUserMessages, deleteUserMessages and addReaction/removeReaction to the pinned-message cache. addMessagesSorted stays as a slim channel-meta path (last_message_at plus the user-reference map). Thread reply state is now owned entirely by the Thread object: reply own_reactions are preserved on message.updated and applied on reaction events via reflectReaction, and a new user.messages.deleted/user.deleted subscription applies bans via applyMessageDeletionForUser. channel.ts no longer handles thread-reply events and never reaches into a Thread instance (getReplies, _extendEventWithOwnReactions and the reaction/delete handlers are main-only). Co-Authored-By: Claude Opus 4.8 --- src/channel.ts | 31 +- src/channel_state.ts | 441 +++++++--------------------- src/thread.ts | 75 ++++- test/unit/channel.test.js | 106 ++----- test/unit/channel_state.test.js | 505 -------------------------------- test/unit/client.test.js | 9 +- test/unit/threads.test.ts | 86 +++++- 7 files changed, 286 insertions(+), 967 deletions(-) diff --git a/src/channel.ts b/src/channel.ts index 4a1b9bc2e..4002a7768 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -1572,11 +1572,8 @@ export class Channel { }, ); - // add any messages to our thread state - if (data.messages) { - this.state.addMessagesSorted(data.messages); - } - + // Thread reply state is owned by the Thread object (Thread.messagePaginator); the returned + // replies are consumed there. The channel message list is owned by channel.messagePaginator. return data; } @@ -2375,21 +2372,16 @@ export class Channel { const formattedMessage = formatMessage(event.message); const isThreadReply = !!event.message.parent_id && !event.message.show_in_channel; - if (event.hard_delete) { - channelState.removeMessage(event.message); - if (!isThreadReply) { + // Thread-only replies are handled by the Thread object; the channel owns the main list. + if (!isThreadReply) { + if (event.hard_delete) { this.messagePaginator.removeItem({ id: event.message.id }); - } - } else { - channelState.addMessageSorted(event.message, false, false); - if (!isThreadReply) { + } else { this.messagePaginator.ingestItem(formattedMessage); } } this.messagePaginator.reflectQuotedMessageUpdate(formattedMessage); - channelState.removeQuotedMessageReferences(event.message); - if (event.message.pinned) { channelState.removePinnedMessage(event.message); } @@ -2501,7 +2493,6 @@ export class Channel { this.messagePaginator.ingestItem(formattedMessage); this.messagePaginator.reflectQuotedMessageUpdate(formattedMessage); } - channelState._updateQuotedMessageReferences({ message: event.message }); if (event.message.pinned) { channelState.addPinnedMessage(event.message); } else { @@ -2886,12 +2877,10 @@ export class Channel { if (!event.message) { return; } - // Main (non-reply) messages are owned by the message paginator; thread replies still live in - // ChannelState (threads are OUT OF SCOPE and stay there). Resolve each from its own source so - // this no longer reads the legacy main message list. - const message = event.message.parent_id - ? this.state.findMessage(event.message.id, event.message.parent_id) - : this.messagePaginator.getItem(event.message.id); + // The channel message list is owned by the paginator; enrich from it. Thread-only replies are + // not in the paginator (getItem returns undefined) — the Thread object preserves their + // own_reactions on its own reply store. + const message = this.messagePaginator.getItem(event.message.id); if (message) { event.message.own_reactions = message.own_reactions; } diff --git a/src/channel_state.ts b/src/channel_state.ts index 48b07a504..6f93bf94d 100644 --- a/src/channel_state.ts +++ b/src/channel_state.ts @@ -69,7 +69,6 @@ export class ChannelState { readonly mutedUsersStore: StateStore; pinnedMessages: Array>; pending_messages: Array; - threads: Record>>; unreadCount: number; membership: ChannelMemberResponse; last_message_at: Date | null; @@ -101,7 +100,6 @@ export class ChannelState { this.syncOwnCapabilitiesFromChannelData(channel?.data); this.pinnedMessages = []; this.pending_messages = []; - this.threads = {}; this.membership = {}; this.unreadCount = 0; /** @@ -262,10 +260,10 @@ export class ChannelState { } /** - * addMessageSorted - Maintain thread-reply state for a single message. + * addMessageSorted - Register a single message's channel-level side effects. * - * The main channel message list lives in `channel.messagePaginator` now; this only appends thread - * replies to `state.threads` and advances `last_message_at`. + * The channel message list lives in `channel.messagePaginator` and thread replies in the + * `Thread` object now; this only advances `last_message_at` and records the user reference. * * @param {MessageResponse} newMessage A new message * @param {boolean} timestampChanged Whether updating a message with changed created_at value. @@ -294,7 +292,11 @@ export class ChannelState { formatMessage(message); /** - * addMessagesSorted - Add the list of messages to state and resorts the messages + * addMessagesSorted - Register channel-level side effects for a list of messages. + * + * The channel message list lives in `channel.messagePaginator` and thread replies in the `Thread` + * object now; this only records the user reference (for user-update propagation) and advances + * `last_message_at`. It is retained until the paginators fully own those concerns. * * @param {Array} newMessages A list of messages * @param {boolean} timestampChanged Whether updating messages with changed created_at value. @@ -307,69 +309,44 @@ export class ChannelState { initializing = false, addIfDoesNotExist = true, ) { + // `timestampChanged` / `initializing` are retained for positional-call compatibility only — + // the message list and thread replies now live in the paginators, so neither affects this + // channel-meta path. (This method is slated for removal once the paginators own last_message_at + // and the user reference map.) + void timestampChanged; + void initializing; for (let i = 0; i < newMessages.length; i += 1) { if (newMessages[i].shadowed && addIfDoesNotExist) { continue; } - // If message is already formatted we can skip the tasks below. + // Already-formatted messages have run through this side-effect path already; skip them. const isMessageFormatted = newMessages[i].created_at instanceof Date; - let message: ReturnType; if (isMessageFormatted) { - message = newMessages[i] as ReturnType; - } else { - message = this.formatMessage(newMessages[i]); - - if (message.user && this._channel?.cid) { - /** - * Store the reference to user for this channel, so that when we have to - * handle updates to user, we can use the reference map, to determine which - * channels need to be updated with updated user object. - */ - this._channel - .getClient() - .state.updateUserReference(message.user, this._channel.cid); - } - - if ( - initializing && - message.id && - this.threads[message.id] && - !this._channel.getClient().preventThreadCleanup - ) { - // If we are initializing the state of channel (e.g., in case of connection recovery), - // then in that case we remove thread related to this message from threads object. - // This way we can ensure that we don't have any stale data in thread object - // and consumer can refetch the replies. - delete this.threads[message.id]; - } - - const shouldSkipLastMessageAtUpdate = - this._channel.getConfig()?.skip_last_msg_update_for_system_msgs && - message.type === 'system'; - - if ( - !shouldSkipLastMessageAtUpdate && - (!this.last_message_at || - message.created_at.getTime() > this.last_message_at.getTime()) - ) { - this.last_message_at = new Date(message.created_at.getTime()); - } + continue; + } + const message = this.formatMessage(newMessages[i]); + + if (message.user && this._channel?.cid) { + /** + * Store the reference to user for this channel, so that when we have to + * handle updates to user, we can use the reference map, to determine which + * channels need to be updated with updated user object. + */ + this._channel + .getClient() + .state.updateUserReference(message.user, this._channel.cid); } - /** - * Add message to thread if applicable and the message was added when querying for replies, - * or the thread already exists. The main channel message list is owned by the paginator. - */ - const parentID = message.parent_id; - if (parentID && !initializing) { - const thread = this.threads[parentID] || []; - this.threads[parentID] = this._addToMessageList( - thread, - message, - timestampChanged, - 'created_at', - addIfDoesNotExist, - ); + const shouldSkipLastMessageAtUpdate = + this._channel.getConfig()?.skip_last_msg_update_for_system_msgs && + message.type === 'system'; + + if ( + !shouldSkipLastMessageAtUpdate && + (!this.last_message_at || + message.created_at.getTime() > this.last_message_at.getTime()) + ) { + this.last_message_at = new Date(message.created_at.getTime()); } } } @@ -412,127 +389,53 @@ export class ChannelState { this.pinnedMessages = result; } + /** + * addReaction - keeps the pinned-message copy's reactions in sync and enriches the passed + * `event.message` with the current user's `own_reactions`. + * + * The channel message list is owned by `channel.messagePaginator` (see `reflectReaction`) and + * thread replies by the `Thread` object; this only maintains the pinned-message cache. + */ addReaction( reaction: ReactionResponse, message?: MessageResponse, enforce_unique?: boolean, ) { - const messageWithReaction = message; - let messageFromState: LocalMessage | undefined; - if (!messageWithReaction) { - messageFromState = this.findMessage(reaction.message_id); - } - - if (!messageWithReaction && !messageFromState) { + if (!message) { return; } - const messageToUpdate = messageWithReaction ?? messageFromState; + const messageWithReaction = message; const updateData = { - id: messageToUpdate?.id, - parent_id: messageToUpdate?.parent_id, - pinned: messageToUpdate?.pinned, - show_in_channel: messageToUpdate?.show_in_channel, + id: messageWithReaction.id, + parent_id: messageWithReaction.parent_id, + pinned: messageWithReaction.pinned, + show_in_channel: messageWithReaction.show_in_channel, }; this._updateMessage(updateData, (msg) => { - if (messageWithReaction) { - const updatedMessage = { ...messageWithReaction }; - // This part will remove own_reactions from what is essentially - // a copy of event.message; we do not want to return that as someone - // else reaction would remove our own_reactions needlessly. This - // only happens when we are not the sender of the reaction. We need - // the variable itself so that the event can be properly enriched - // later on. - messageWithReaction.own_reactions = this._addOwnReactionToMessage( - msg.own_reactions, - reaction, - enforce_unique, - ); - // Whenever we are the ones sending the reaction, the helper enriches - // own_reactions as normal so we can use that, otherwise we fallback - // to whatever state we had. - updatedMessage.own_reactions = - this._channel.getClient().userID === reaction.user_id - ? messageWithReaction.own_reactions - : msg.own_reactions; - return this.formatMessage(updatedMessage); - } - - if (messageFromState) { - return this._addReactionToState(messageFromState, reaction, enforce_unique); - } - - return msg; + const updatedMessage = { ...messageWithReaction }; + // This part will remove own_reactions from what is essentially + // a copy of event.message; we do not want to return that as someone + // else reaction would remove our own_reactions needlessly. This + // only happens when we are not the sender of the reaction. We need + // the variable itself so that the event can be properly enriched + // later on. + messageWithReaction.own_reactions = this._addOwnReactionToMessage( + msg.own_reactions, + reaction, + enforce_unique, + ); + // Whenever we are the ones sending the reaction, the helper enriches + // own_reactions as normal so we can use that, otherwise we fallback + // to whatever state we had. + updatedMessage.own_reactions = + this._channel.getClient().userID === reaction.user_id + ? messageWithReaction.own_reactions + : msg.own_reactions; + return this.formatMessage(updatedMessage); }); - return messageWithReaction ?? messageFromState; - } - - _addReactionToState( - messageFromState: LocalMessage, - reaction: ReactionResponse, - enforce_unique?: boolean, - ) { - if (!messageFromState.reaction_groups) { - messageFromState.reaction_groups = {}; - } - - // 1. Firstly, get rid of all of our own reactions from the reaction_groups - // if enforce_unique is enabled. - if (enforce_unique) { - for (const ownReaction of messageFromState.own_reactions ?? []) { - const oldOwnReactionTypeData = messageFromState.reaction_groups[ownReaction.type]; - messageFromState.reaction_groups[ownReaction.type] = { - ...oldOwnReactionTypeData, - count: oldOwnReactionTypeData.count - 1, - sum_scores: oldOwnReactionTypeData.sum_scores - (ownReaction.score ?? 1), - }; - // If there are no reactions left in this group, simply remove it. - if (messageFromState.reaction_groups[ownReaction.type].count < 1) { - delete messageFromState.reaction_groups[ownReaction.type]; - } - } - } - - const newReactionGroups = messageFromState.reaction_groups; - const oldReactionTypeData = newReactionGroups[reaction.type]; - const score = reaction.score ?? 1; - - // 2. Next, update the reaction_groups with the new reaction. - messageFromState.reaction_groups[reaction.type] = oldReactionTypeData - ? { - ...oldReactionTypeData, - count: oldReactionTypeData.count + 1, - sum_scores: oldReactionTypeData.sum_scores + score, - last_reaction_at: reaction.created_at, - } - : { - count: 1, - first_reaction_at: reaction.created_at, - last_reaction_at: reaction.created_at, - sum_scores: score, - }; - - // 3. Update the own_reactions with the new reaction. - messageFromState.own_reactions = this._addOwnReactionToMessage( - messageFromState.own_reactions, - reaction, - enforce_unique, - ); - - // 4. Finally, update the latest_reactions with the new reaction, - // while respecting enforce_unique. - const userId = this._channel.getClient().userID; - messageFromState.latest_reactions = enforce_unique - ? [ - ...(messageFromState.latest_reactions || []).filter( - (r) => r.user_id !== userId, - ), - reaction, - ] - : [...(messageFromState.latest_reactions || []), reaction]; - - return messageFromState; + return messageWithReaction; } _addOwnReactionToMessage( @@ -566,133 +469,47 @@ export class ChannelState { return ownReactions; } + /** + * removeReaction - keeps the pinned-message copy's reactions in sync (see `addReaction`). + */ removeReaction(reaction: ReactionResponse, message?: MessageResponse) { - const messageWithRemovedReaction = message; - let messageFromState: LocalMessage | undefined; - if (!messageWithRemovedReaction) { - messageFromState = this.findMessage(reaction.message_id); - } - - if (!messageWithRemovedReaction && !messageFromState) { + if (!message) { return; } - const messageToUpdate = messageWithRemovedReaction ?? messageFromState; + const messageWithRemovedReaction = message; const updateData = { - id: messageToUpdate?.id, - parent_id: messageToUpdate?.parent_id, - pinned: messageToUpdate?.pinned, - show_in_channel: messageToUpdate?.show_in_channel, + id: messageWithRemovedReaction.id, + parent_id: messageWithRemovedReaction.parent_id, + pinned: messageWithRemovedReaction.pinned, + show_in_channel: messageWithRemovedReaction.show_in_channel, }; this._updateMessage(updateData, (msg) => { - if (messageWithRemovedReaction) { - messageWithRemovedReaction.own_reactions = this._removeOwnReactionFromMessage( - msg.own_reactions, - reaction, - ); - return this.formatMessage(messageWithRemovedReaction); - } - - if (messageFromState) { - return this._removeReactionFromState(messageFromState, reaction); - } - - return msg; + messageWithRemovedReaction.own_reactions = this._removeOwnReactionFromMessage( + msg.own_reactions, + reaction, + ); + return this.formatMessage(messageWithRemovedReaction); }); return messageWithRemovedReaction; } - _removeReactionFromState(messageFromState: LocalMessage, reaction: ReactionResponse) { - const reactionToRemove = messageFromState.own_reactions?.find( - (r) => r.type === reaction.type, - ); - if (reactionToRemove && messageFromState.reaction_groups?.[reactionToRemove.type]) { - const newReactionGroup = messageFromState.reaction_groups[reactionToRemove.type]; - messageFromState.reaction_groups[reactionToRemove.type] = { - ...newReactionGroup, - count: newReactionGroup.count - 1, - sum_scores: newReactionGroup.sum_scores - (reactionToRemove.score ?? 1), - }; - // If there are no reactions left in this group, simply remove it. - if (messageFromState.reaction_groups[reactionToRemove.type].count < 1) { - delete messageFromState.reaction_groups[reactionToRemove.type]; - } - } - messageFromState.own_reactions = messageFromState.own_reactions?.filter( - (r) => r.type !== reaction.type, - ); - const userId = this._channel.getClient().userID; - messageFromState.latest_reactions = messageFromState.latest_reactions?.filter( - (r) => !(r.user_id === userId && r.type === reaction.type), - ); - return messageFromState; - } - - _updateQuotedMessageReferences({ - message, - remove, - }: { - message: MessageResponse; - remove?: boolean; - }) { - const parseMessage = (m: ReturnType) => - ({ - ...m, - created_at: m.created_at.toISOString(), - pinned_at: m.pinned_at?.toISOString(), - updated_at: m.updated_at?.toISOString(), - }) as unknown as MessageResponse; - - const update = (messages: LocalMessage[]) => { - const updatedMessages = messages.reduce((acc, msg) => { - if (msg.quoted_message_id === message.id) { - acc.push({ - ...parseMessage(msg), - quoted_message: remove ? { ...message, attachments: [] } : message, - }); - } - return acc; - }, []); - this.addMessagesSorted(updatedMessages, true); - }; - - // Main-list quoted-reference updates are handled by messagePaginator.reflectQuotedMessageUpdate; - // here we only keep thread replies in sync. - if (message.parent_id && this.threads[message.parent_id]) { - update(this.threads[message.parent_id]); - } - } - - removeQuotedMessageReferences(message: MessageResponse) { - this._updateQuotedMessageReferences({ message, remove: true }); - } - /** - * Updates all instances of given message in channel state + * Updates the pinned-message copy of the given message. The channel message list is owned by + * `channel.messagePaginator` and thread replies by the `Thread` object. * @param message * @param updateFunc */ _updateMessage( message: { id?: string; - parent_id?: string; pinned?: boolean; - show_in_channel?: boolean; }, updateFunc: ( msg: ReturnType, ) => ReturnType, ) { - const { parent_id, pinned } = message; - - if (parent_id && this.threads[parent_id]) { - const thread = this.threads[parent_id]; - const msgIndex = thread.findIndex((msg) => msg.id === message.id); - if (msgIndex !== -1) { - thread[msgIndex] = updateFunc(thread[msgIndex]); - this.threads[parent_id] = thread; - } - } + const { pinned } = message; if (pinned) { const msgIndex = this.pinnedMessages.findIndex((msg) => msg.id === message.id); @@ -739,29 +556,6 @@ export class ChannelState { ); } - /** - * removeMessage - Description - * - * @param {{ id: string; parent_id?: string }} messageToRemove Object of the message to remove. Needs to have at id specified. - * - * @return {boolean} Returns if the message was removed - */ - removeMessage(messageToRemove: { id: string; parent_id?: string }) { - // The main channel message list is owned by the paginator (use messagePaginator.removeItem); - // this only removes thread replies from state.threads. - if (messageToRemove.parent_id && this.threads[messageToRemove.parent_id]) { - const { removed, result: threadMessages } = this.removeMessageFromArray( - this.threads[messageToRemove.parent_id], - messageToRemove, - ); - - this.threads[messageToRemove.parent_id] = threadMessages; - return removed; - } - - return false; - } - removeMessageFromArray = ( msgArray: Array>, msg: { id: string; parent_id?: string }, @@ -779,24 +573,15 @@ export class ChannelState { * @param {UserResponse} user */ updateUserMessages = (user: UserResponse) => { - const _updateUserMessages = ( - messages: Array>, - user: UserResponse, - ) => { - for (let i = 0; i < messages.length; i++) { - const m = messages[i]; - if (m.user?.id === user.id) { - messages[i] = { ...m, user }; - } + // The channel message list updates user references on the paginator + // (messagePaginator.reflectUserUpdate) and thread replies via the Thread object; this keeps the + // pinned-message cache in sync. + for (let i = 0; i < this.pinnedMessages.length; i++) { + const m = this.pinnedMessages[i]; + if (m.user?.id === user.id) { + this.pinnedMessages[i] = { ...m, user }; } - }; - - // Main-list user references are updated on the paginator (messagePaginator.reflectUserUpdate). - for (const parentId in this.threads) { - _updateUserMessages(this.threads[parentId], user); } - - _updateUserMessages(this.pinnedMessages, user); }; /** @@ -810,16 +595,9 @@ export class ChannelState { hardDelete = false, deletedAt?: LocalMessage['deleted_at'], ) => { - // Main-list deletions are applied on the paginator (messagePaginator.applyMessageDeletionForUser). - for (const parentId in this.threads) { - _deleteUserMessages({ - messages: this.threads[parentId], - user, - hardDelete, - deletedAt: deletedAt ?? null, - }); - } - + // The channel message list applies deletions on the paginator + // (messagePaginator.applyMessageDeletionForUser) and thread replies via the Thread object; this + // keeps the pinned-message cache in sync. _deleteUserMessages({ messages: this.pinnedMessages, user, @@ -857,25 +635,4 @@ export class ChannelState { clearMessages() { this.pinnedMessages = []; } - - /** - * findMessage - Finds a message inside the state - * - * @param {string} messageId The id of the message - * @param {string} parentMessageId The id of the parent message, if we want load a thread reply - * - * @return {ReturnType} Returns the message, or undefined if the message wasn't found - */ - findMessage(messageId: string, parentMessageId?: string) { - if (parentMessageId) { - const messages = this.threads[parentMessageId]; - if (!messages) { - return undefined; - } - return messages.find((m) => m.id === messageId); - } - - // Main channel messages live in the paginator — use channel.messagePaginator.getItem. - return undefined; - } } diff --git a/src/thread.ts b/src/thread.ts index 0302c2463..36d38e294 100644 --- a/src/thread.ts +++ b/src/thread.ts @@ -413,6 +413,7 @@ export class Thread extends WithSubscriptions { this.addUnsubscribeFunction(this.subscribeRepliesUnread()); this.addUnsubscribeFunction(this.subscribeMessageDeleted()); this.addUnsubscribeFunction(this.subscribeMessageUpdated()); + this.addUnsubscribeFunction(this.subscribeUserMessagesDeleted()); }; private subscribeThreadUpdated = () => @@ -612,23 +613,79 @@ export class Thread extends WithSubscriptions { }).unsubscribe; private subscribeMessageUpdated = () => { - const eventTypes: EventTypes[] = [ - 'message.updated', - 'message.undeleted', + const messageUpdateTypes: EventTypes[] = ['message.updated', 'message.undeleted']; + const reactionTypes: EventTypes[] = [ 'reaction.new', 'reaction.deleted', 'reaction.updated', ]; - const unsubscribeFunctions = eventTypes.map( + const unsubscribeMessageUpdated = messageUpdateTypes.map( (eventType) => this.client.on(eventType, (event) => { - if (event.message) { - this.updateParentMessageOrReplyLocally(event.message); - this.messagePaginator.reflectQuotedMessageUpdate( - formatMessage(event.message), - ); + if (!event.message) return; + // A `message.updated` WS event carries `own_reactions: []`; upserting it verbatim would + // wipe the current user's reactions on a reply edit. The reply paginator is this thread's + // own source of truth (the channel no longer enriches reply events), so preserve the + // existing reply's `own_reactions`. + const message = + event.message.parent_id === this.id + ? { + ...event.message, + own_reactions: + this.messagePaginator.getItem(event.message.id)?.own_reactions ?? + event.message.own_reactions, + } + : event.message; + this.updateParentMessageOrReplyLocally(message); + this.messagePaginator.reflectQuotedMessageUpdate(formatMessage(event.message)); + }).unsubscribe, + ); + + const unsubscribeReactions = reactionTypes.map( + (eventType) => + this.client.on(eventType, (event) => { + if (!event.message || !event.reaction) return; + const { message, reaction } = event; + if (message.parent_id === this.id) { + // Preserve/apply the current user's `own_reactions` off the reply paginator itself, + // independently of the channel (mirrors the channel's main-list reflectReaction). + this.messagePaginator.reflectReaction({ + enforceUnique: eventType === 'reaction.updated', + message, + reaction, + removed: eventType === 'reaction.deleted', + }); + } else if (!message.parent_id && message.id === this.id) { + this.updateParentMessageLocally({ message }); } + this.messagePaginator.reflectQuotedMessageUpdate(formatMessage(message)); + }).unsubscribe, + ); + + const unsubscribeFunctions = [...unsubscribeMessageUpdated, ...unsubscribeReactions]; + + return () => unsubscribeFunctions.forEach((unsubscribe) => unsubscribe()); + }; + + private subscribeUserMessagesDeleted = () => { + // Apply a user ban / deletion to this thread's own reply list. Previously + // channel.state.deleteUserMessages marked banned-user replies deleted in the (now removed) + // channel.state.threads shadow; the reply paginator is the thread's source of truth now. + const eventTypes: EventTypes[] = ['user.messages.deleted', 'user.deleted']; + + const unsubscribeFunctions = eventTypes.map( + (eventType) => + this.client.on(eventType, (event) => { + if (!event.user) return; + // user.deleted carries the deletion time on the user; user.messages.deleted on the event. + const deletedAtSource = + eventType === 'user.deleted' ? event.user.deleted_at : event.created_at; + this.messagePaginator.applyMessageDeletionForUser({ + userId: event.user.id, + hardDelete: !!event.hard_delete, + deletedAt: deletedAtSource ? new Date(deletedAtSource) : new Date(), + }); }).unsubscribe, ); diff --git a/test/unit/channel.test.js b/test/unit/channel.test.js index 27ac86cde..176854d90 100644 --- a/test/unit/channel.test.js +++ b/test/unit/channel.test.js @@ -1118,21 +1118,15 @@ describe('Channel _handleChannelEvent', function () { const pinnedMessages = [messageSet1[0], messageSet1[1], messageSet2[0]]; + // Thread-reply deletion is owned by the Thread object (covered in threads.test.ts); this + // suite exercises the pinned-message cache that ChannelState still keeps. const setupChannel = (channel) => { - channel.state.addMessagesSorted(messageSet1); - channel.state.addMessagesSorted(messageSet2, false, false, true, 'new'); - - // pinned messages channel.state.addPinnedMessages(pinnedMessages); - - // thread replies - channel.state.addMessagesSorted(thread1); }; - it('removes the pinned and thread messages on hard delete', () => { + it('removes the pinned messages on hard delete', () => { setupChannel(channel); expect(channel.state.pinnedMessages).toHaveLength(pinnedMessages.length); - expect(channel.state.threads[parent_id]).toHaveLength(thread1.length); const event = { type: 'user.messages.deleted', @@ -1182,12 +1176,10 @@ describe('Channel _handleChannelEvent', function () { }; channel.state.pinnedMessages.forEach(check); - Object.values(channel.state.threads).forEach((replies) => replies.forEach(check)); }); - it('removes the pinned and thread messages on soft delete', () => { + it('removes the pinned messages on soft delete', () => { setupChannel(channel); expect(channel.state.pinnedMessages).toHaveLength(pinnedMessages.length); - expect(channel.state.threads[parent_id]).toHaveLength(thread1.length); const event = { type: 'user.messages.deleted', @@ -1224,7 +1216,6 @@ describe('Channel _handleChannelEvent', function () { }; channel.state.pinnedMessages.forEach(check); - Object.values(channel.state.threads).forEach((replies) => replies.forEach(check)); }); it('updates messagePaginator items on soft delete', () => { @@ -1817,52 +1808,34 @@ describe('Channel _handleChannelEvent', function () { type: 'wow', }, ]; - const testCases = [ - [generateMsg({ own_reactions })], // channel message - [generateMsg({ id: '0' }), generateMsg({ parent_id: '0', own_reactions })], // thread message - ]; - - testCases.forEach((messages) => { - channel.state.addMessagesSorted(messages); - // Main (non-reply) messages are resolved from the paginator by - // _extendEventWithOwnReactions; thread replies stay resolved via channel.state. - seedLatestWindow( - channel, - messages.filter((m) => !m.parent_id), - ); - const message = messages[messages.length - 1]; + // Thread-reply own_reactions preservation is owned by the Thread object (covered in + // threads.test.ts); at the channel level only the paginator-backed message list is enriched. + const message = generateMsg({ own_reactions }); + seedLatestWindow(channel, [message]); - const eventTypes = ['message.updated', 'message.deleted']; + ['message.updated', 'message.deleted'].forEach((eventType) => { + let receivedEvent; + channel.on(eventType, (e) => (receivedEvent = e)); - eventTypes.forEach((eventType) => { - let receivedEvent; - channel.on(eventType, (e) => (receivedEvent = e)); - - const event = { - type: eventType, - // own_reactions is always [] in WS events - message: { ...message, own_reactions: [] }, - }; - channel._handleChannelEvent(event); - channel._callChannelListeners(event); + const event = { + type: eventType, + // own_reactions is always [] in WS events + message: { ...message, own_reactions: [] }, + }; + channel._handleChannelEvent(event); + channel._callChannelListeners(event); - const stored = message.parent_id - ? channel.state.findMessage(message.id, message.parent_id) - : channel.messagePaginator.getItem(message.id); - expect(stored.own_reactions.length).to.equal(own_reactions.length); - expect(receivedEvent.message.own_reactions.length).to.equal(own_reactions.length); - }); + const stored = channel.messagePaginator.getItem(message.id); + expect(stored.own_reactions.length).to.equal(own_reactions.length); + expect(receivedEvent.message.own_reactions.length).to.equal(own_reactions.length); }); }); it('should update quoted_message references on "message.updated" and "message.deleted" event', () => { + // Thread-reply quoted-message updates are owned by the Thread object (Thread.messagePaginator + // .reflectQuotedMessageUpdate); this exercises the channel's paginator-backed message list. const originalText = 'XX'; const updatedText = 'YY'; - const parent_id = '0'; - const parentMesssage = generateMsg({ - date: new Date(0).toISOString(), - id: parent_id, - }); const quoted_message = generateMsg({ date: new Date(2).toISOString(), id: 'quoted-message', @@ -1875,32 +1848,13 @@ describe('Channel _handleChannelEvent', function () { quoted_message_id: quoted_message.id, }); const updatedQuotedMessage = { ...quoted_message, text: updatedText }; - const updatedQuotedThreadReply = { ...quoted_message, parent_id, text: updatedText }; - [ - [quoted_message, quotingMessage], // channel message - [ - parentMesssage, - { ...quoted_message, parent_id }, - { ...quotingMessage, parent_id }, - ], // thread message - ].forEach((messages) => { - ['message.updated', 'message.deleted'].forEach((eventType) => { - channel.state.addMessagesSorted(messages); - const isThread = messages.length === 3; - if (!isThread) seedLatestWindow(channel, messages); - const quotingMessage = messages[messages.length - 1]; - const event = { - type: eventType, - message: isThread ? updatedQuotedThreadReply : updatedQuotedMessage, - }; - channel._handleChannelEvent(event); - const stored = isThread - ? channel.state.findMessage(quotingMessage.id, quotingMessage.parent_id) - : channel.messagePaginator.getItem(quotingMessage.id); - expect(stored.quoted_message.text).to.equal(updatedQuotedMessage.text); - channel.state.clearMessages(); - channel.messagePaginator.clearStateAndCache(); - }); + ['message.updated', 'message.deleted'].forEach((eventType) => { + seedLatestWindow(channel, [quoted_message, quotingMessage]); + const event = { type: eventType, message: updatedQuotedMessage }; + channel._handleChannelEvent(event); + const stored = channel.messagePaginator.getItem(quotingMessage.id); + expect(stored.quoted_message.text).to.equal(updatedQuotedMessage.text); + channel.messagePaginator.clearStateAndCache(); }); }); diff --git a/test/unit/channel_state.test.js b/test/unit/channel_state.test.js index fdd93ea9b..90796cc19 100644 --- a/test/unit/channel_state.test.js +++ b/test/unit/channel_state.test.js @@ -62,364 +62,6 @@ describe('ChannelState addMessagesSorted', function () { expect(state.pinnedMessages[1].id).to.be.equal('3'); expect(state.pinnedMessages[2].id).to.be.equal('2'); }); - - it('should add thread reply preview', async function () { - // these message previews are used by UI SDKs - const parentMessage = generateMsg({ - id: 'parent_id', - date: '2020-01-01T00:00:00.001Z', - }); - const threadReplyPreview = generateMsg({ - id: '2', - date: new Date('2020-01-01T00:00:00.001Z'), - parent_id: 'parent_id', - }); - state.addMessageSorted(parentMessage); - state.addMessageSorted(threadReplyPreview); - const thread = state.threads[parentMessage.id]; - - expect(thread.length).to.be.equal(1); - expect(thread[0].id).to.be.equal(threadReplyPreview.id); - }); -}); - -describe('ChannelState reactions', () => { - const message = generateMsg(); - let state; - beforeEach(() => { - const client = new StreamChat(); - client.userID = 'observer'; - state = new ChannelState(new Channel(client, 'live', 'stream', {})); - state.addMessageSorted(message); - }); - describe('_addReactionToState', () => { - let addOwnReactionToMessageSpy; - let reaction; - let userID; - let baseMessage; - - beforeEach(() => { - userID = state._channel.getClient().userID; - baseMessage = { - id: 'msg-1', - own_reactions: [], - latest_reactions: [], - reaction_groups: {}, - }; - - reaction = { - message_id: baseMessage.id, - type: 'like', - user_id: userID, - score: 2, - created_at: new Date(), - }; - - addOwnReactionToMessageSpy = vi.spyOn(state, '_addOwnReactionToMessage'); - }); - - afterEach(() => { - vi.resetAllMocks(); - }); - - it('should create a new reaction group if none exist', () => { - const messageFromState = { ...baseMessage, reaction_groups: undefined }; - const result = state._addReactionToState(messageFromState, reaction); - - expect(result.reaction_groups).to.deep.equal({ - like: { - count: 1, - sum_scores: 2, - first_reaction_at: reaction.created_at, - last_reaction_at: reaction.created_at, - }, - }); - }); - - it('should update existing reaction group', () => { - const existing = { - count: 1, - sum_scores: 1, - first_reaction_at: new Date(Date.now() - 5000), - last_reaction_at: new Date(Date.now() - 5000), - }; - const messageFromState = { - ...baseMessage, - reaction_groups: { like: { ...existing } }, - }; - - const result = state._addReactionToState(messageFromState, reaction); - - expect(result.reaction_groups.like.count).to.equal(2); - expect(result.reaction_groups.like.sum_scores).to.equal(3); - expect(result.reaction_groups.like.last_reaction_at).to.equal(reaction.created_at); - }); - - it('should remove previous own reactions from reaction_groups if enforce_unique is true', () => { - const oldReactions = [ - { - type: 'clap', - user_id: userID, - score: 1, - }, - { - type: 'wow', - user_id: userID, - score: 2, - }, - ]; - - const messageFromState = { - ...baseMessage, - own_reactions: oldReactions, - reaction_groups: { - clap: { - count: 1, - sum_scores: 1, - }, - wow: { - count: 1, - sum_scores: 2, - }, - }, - }; - - const result = state._addReactionToState(messageFromState, reaction, true); - - expect(result.reaction_groups.clap).to.be.undefined; - expect(result.reaction_groups.wow).to.be.undefined; - expect(result.reaction_groups.like.count).to.equal(1); - }); - - it('should preserve other users’ reactions when enforce_unique is true', () => { - const newOwnReaction = { - ...reaction, - type: 'wow', - }; - const messageFromState = { - ...baseMessage, - own_reactions: [ - { type: 'like', user_id: userID, score: 1 }, - { type: 'clap', user_id: userID, score: 1 }, - ], - latest_reactions: [ - { type: 'like', user_id: userID, score: 1 }, - { type: 'clap', user_id: userID, score: 1 }, - { type: 'clap', user_id: 'other-user', score: 1 }, - ], - reaction_groups: { - like: { count: 1, sum_scores: 1 }, - clap: { count: 2, sum_scores: 2 }, - }, - }; - - const result = state._addReactionToState(messageFromState, newOwnReaction, true); - - Object.keys(result.reaction_groups).forEach((key) => { - delete result.reaction_groups[key].first_reaction_at; - delete result.reaction_groups[key].last_reaction_at; - }); - - expect(result.reaction_groups).to.deep.equal({ - clap: { - count: 1, - sum_scores: 1, - }, - wow: { - count: 1, - sum_scores: 2, - }, - }); - expect(result.latest_reactions).to.deep.equal([ - { type: 'clap', user_id: 'other-user', score: 1 }, - newOwnReaction, - ]); - expect(result.own_reactions).to.deep.equal([newOwnReaction]); - }); - - it('should correctly update own_reactions with the new reaction', () => { - const oldOwnReactions = [{ type: 'clap', user_id: userID, score: 1 }]; - const messageFromState = { - ...baseMessage, - own_reactions: oldOwnReactions, - reaction_groups: { - clap: { count: 1, sum_scores: 1 }, - }, - }; - const result1 = state._addReactionToState(messageFromState, reaction); - - expect(addOwnReactionToMessageSpy).toHaveBeenCalledTimes(1); - expect(result1.own_reactions).to.deep.equal([...oldOwnReactions, reaction]); - - vi.clearAllMocks(); - - const newerReaction = { ...reaction, type: 'wow' }; - const result2 = state._addReactionToState(result1, newerReaction, true); - - expect(addOwnReactionToMessageSpy).toHaveBeenCalledTimes(1); - expect(result2.own_reactions).to.deep.equal([newerReaction]); - }); - - it('should overwrite own reaction in latest_reactions if enforce_unique is true', () => { - const oldReaction = { - type: 'clap', - user_id: userID, - }; - - const messageFromState = { - ...baseMessage, - latest_reactions: [oldReaction], - }; - - const result = state._addReactionToState(messageFromState, reaction, true); - - expect(result.latest_reactions).to.deep.equal([reaction]); - }); - - it('should append to latest_reactions if enforce_unique is false', () => { - const messageFromState = { - ...baseMessage, - latest_reactions: [], - }; - - const result = state._addReactionToState(messageFromState, reaction, false); - - expect(result.latest_reactions.length).to.equal(1); - expect(result.latest_reactions[0]).to.deep.equal(reaction); - }); - - it('should handle empty own_reactions and latest_reactions gracefully', () => { - const messageFromState = { - ...baseMessage, - own_reactions: undefined, - latest_reactions: undefined, - }; - - const result = state._addReactionToState(messageFromState, reaction, true); - - expect(result.own_reactions).to.deep.equal([reaction]); - expect(result.latest_reactions).to.deep.equal([reaction]); - }); - }); - - describe('_removeReactionFromState', () => { - let reaction; - let userID; - let baseMessage; - - beforeEach(() => { - userID = state._channel.getClient().userID; - - baseMessage = { - id: 'messageFromState-1', - own_reactions: [ - { type: 'like', user_id: userID, score: 2 }, - { type: 'clap', user_id: userID, score: 1 }, - ], - latest_reactions: [ - { type: 'like', user_id: userID, score: 2 }, - { type: 'clap', user_id: userID, score: 1 }, - { type: 'wow', user_id: 'other-user', score: 1 }, - ], - reaction_groups: { - like: { - count: 1, - sum_scores: 2, - }, - clap: { - count: 1, - sum_scores: 1, - }, - wow: { - count: 1, - sum_scores: 1, - }, - }, - }; - - reaction = { - type: 'like', - user_id: userID, - score: 2, - }; - }); - - afterEach(() => { - vi.resetAllMocks(); - }); - - it('should remove the reaction from own_reactions', () => { - const result = state._removeReactionFromState({ ...baseMessage }, reaction); - expect(result.own_reactions.some((r) => r.type === 'like')).to.be.false; - }); - - it('should decrement the count and sum_scores in the reaction group', () => { - const result = state._removeReactionFromState({ ...baseMessage }, reaction); - expect(result.reaction_groups.like).to.be.undefined; - }); - - it('should remove the reaction from latest_reactions for the same user', () => { - const result = state._removeReactionFromState({ ...baseMessage }, reaction); - expect( - result.latest_reactions.some((r) => r.type === 'like' && r.user_id === userID), - ).to.be.false; - }); - - it('should preserve other users’ reactions in latest_reactions', () => { - const reactionToRemove = { - type: 'wow', - user_id: userID, - }; - const result = state._removeReactionFromState({ ...baseMessage }, reactionToRemove); - expect( - result.latest_reactions.some( - (r) => r.user_id === 'other-user' && r.type === 'wow', - ), - ).to.be.true; - }); - - it('should handle when reaction_groups count becomes 0 by deleting the group', () => { - const reactionToRemove = { - type: 'clap', - user_id: userID, - score: 1, - }; - const result = state._removeReactionFromState({ ...baseMessage }, reactionToRemove); - expect(result.reaction_groups.clap).to.be.undefined; - }); - - it('should handle when own_reactions is undefined', () => { - const messageFromState = { - ...baseMessage, - own_reactions: undefined, - }; - const result = state._removeReactionFromState(messageFromState, reaction); - expect(result.own_reactions).to.be.undefined; - }); - - it('should handle when latest_reactions is undefined', () => { - const messageFromState = { - ...baseMessage, - latest_reactions: undefined, - }; - const result = state._removeReactionFromState(messageFromState, reaction); - expect(result.latest_reactions).to.be.undefined; - }); - - it('should not crash if reaction group does not exist', () => { - const messageFromState = { - ...baseMessage, - reaction_groups: { - wow: { - count: 1, - sum_scores: 1, - }, - }, - }; - const result = state._removeReactionFromState(messageFromState, reaction); - expect(result.reaction_groups.wow).to.exist; - }); - }); }); describe('ChannelState isUpToDate', () => { @@ -616,34 +258,6 @@ describe('deleteUserMessages — quoted_message regression (#1736)', () => { state = new ChannelState(channel); }); - it('does not throw when hard-deleting a thread reply that quotes another same-user reply', () => { - const user1 = generateUser(); - const parent = generateMsg({ user: user1, id: 'parent-id' }); - const reply1 = generateMsg({ - user: user1, - parent_id: parent.id, - date: '2020-01-01T00:00:01.000Z', - }); - const reply2 = generateMsg({ - user: user1, - parent_id: parent.id, - date: '2020-01-01T00:00:02.000Z', - quoted_message: reply1, - quoted_message_id: reply1.id, - }); - - state.addMessagesSorted([parent, reply1, reply2]); - expect(state.threads[parent.id]).to.have.length(2); - - expect(() => state.deleteUserMessages(user1, true)).not.to.throw(); - - const thread = state.threads[parent.id]; - expect(thread).to.have.length(2); - expect(thread[0].type).to.be.equal('deleted'); - expect(thread[1].type).to.be.equal('deleted'); - expect(thread[1].quoted_message).to.be.equal(undefined); - }); - it('does not throw when hard-deleting a pinned message that quotes another same-user pinned message', () => { const user1 = generateUser(); const m1 = generateMsg({ @@ -672,83 +286,6 @@ describe('deleteUserMessages — quoted_message regression (#1736)', () => { const pinnedQuoter = state.pinnedMessages.find((m) => m.id === m2.id); expect(pinnedQuoter.quoted_message).to.be.equal(undefined); }); - - it('soft-deletes a thread reply that quotes another same-user reply and marks the quoted_message as deleted', () => { - const user1 = generateUser(); - const parent = generateMsg({ user: user1, id: 'parent-id' }); - const reply1 = generateMsg({ - user: user1, - parent_id: parent.id, - date: '2020-01-01T00:00:01.000Z', - }); - const reply2 = generateMsg({ - user: user1, - parent_id: parent.id, - date: '2020-01-01T00:00:02.000Z', - quoted_message: reply1, - quoted_message_id: reply1.id, - }); - - state.addMessagesSorted([parent, reply1, reply2]); - - expect(() => state.deleteUserMessages(user1, false)).not.to.throw(); - - const thread = state.threads[parent.id]; - expect(thread[0].type).to.be.equal('deleted'); - expect(thread[1].type).to.be.equal('deleted'); - // Soft-delete preserves message content via the spread path. - expect(thread[1].text).to.be.equal(reply2.text); - // quoted_message reference is replaced with a deleted placeholder. - expect(thread[1].quoted_message).to.not.be.equal(undefined); - expect(thread[1].quoted_message.id).to.be.equal(reply1.id); - expect(thread[1].quoted_message.type).to.be.equal('deleted'); - }); - - it('continues processing later thread replies after encountering a self-quote on hard-delete', () => { - const user1 = generateUser(); - const user2 = generateUser(); - const parent = generateMsg({ user: user1, id: 'parent-id' }); - const rA = generateMsg({ - user: user2, - parent_id: parent.id, - date: '2020-01-01T00:00:01.000Z', - }); - const r1 = generateMsg({ - user: user1, - parent_id: parent.id, - date: '2020-01-01T00:00:02.000Z', - }); - const r2 = generateMsg({ - user: user1, - parent_id: parent.id, - date: '2020-01-01T00:00:03.000Z', - quoted_message: r1, - quoted_message_id: r1.id, - }); - const rB = generateMsg({ - user: user1, - parent_id: parent.id, - date: '2020-01-01T00:00:04.000Z', - }); - const rC = generateMsg({ - user: user2, - parent_id: parent.id, - date: '2020-01-01T00:00:05.000Z', - }); - - state.addMessagesSorted([parent, rA, r1, r2, rB, rC]); - - expect(() => state.deleteUserMessages(user1, true)).not.to.throw(); - - const thread = state.threads[parent.id]; - const byId = (id) => thread.find((m) => m.id === id); - expect(byId(rA.id).type).to.be.equal('regular'); - expect(byId(r1.id).type).to.be.equal('deleted'); - expect(byId(r2.id).type).to.be.equal('deleted'); - // rB sits after the self-quote pair — previously the throw aborted the loop here. - expect(byId(rB.id).type).to.be.equal('deleted'); - expect(byId(rC.id).type).to.be.equal('regular'); - }); }); describe('updateUserMessages', () => { @@ -1130,45 +667,3 @@ describe('ChannelState own capabilities store', () => { }); }); }); - -describe('findMessage', () => { - let state; - - beforeEach(() => { - const client = new StreamChat(); - client.userID = 'userId'; - const channel = new Channel(client, 'type', 'id', {}); - client._addChannelConfig({ cid: channel.cid, config: {} }); - state = new ChannelState(channel); - }); - - it('message not found', async () => { - state.addMessagesSorted([generateMsg({ id: '5' }), generateMsg({ id: '6' })]); - - expect(state.findMessage('12')).to.eql(undefined); - }); - - describe('if message is a thread reply', () => { - it('message found', async () => { - const messageId = '8'; - const parentMessageId = '5'; - const parentMessage = generateMsg({ id: parentMessageId }); - const reply = generateMsg({ id: messageId, parent_id: parentMessageId }); - state.addMessagesSorted([parentMessage]); - state.addMessagesSorted([reply]); - - expect(state.findMessage(messageId, parentMessageId).id).to.eql(messageId); - }); - - it('message not found', async () => { - const messageId = '8'; - const parentMessageId = '5'; - const parentMessage = generateMsg({ id: parentMessageId }); - const reply = generateMsg({ id: messageId, parent_id: parentMessageId }); - state.addMessagesSorted([parentMessage]); - state.addMessagesSorted([reply]); - - expect(state.findMessage(messageId, `not${parentMessageId}`)).to.eql(undefined); - }); - }); -}); diff --git a/test/unit/client.test.js b/test/unit/client.test.js index 58b7bf6ec..72e093193 100644 --- a/test/unit/client.test.js +++ b/test/unit/client.test.js @@ -1597,6 +1597,9 @@ describe('user.messages.deleted', () => { const pinnedMessages = [messageSet1[0], messageSet1[1], messageSet2[0]]; + // Thread-reply deletion is owned by the Thread object (covered in threads.test.ts); this suite + // exercises the pinned-message cache. addMessagesSorted still registers the user->channel + // reference (client.state.userChannelReferences) that the client-level deletion loop walks. const setupChannel = (type, id) => { const channel = client.channel(type, id); channel.state.addMessagesSorted(messageSet1); @@ -1605,10 +1608,7 @@ describe('user.messages.deleted', () => { // pinned messages channel.state.addPinnedMessages(pinnedMessages); - // thread replies - channel.state.addMessagesSorted(thread1); expect(channel.state.pinnedMessages).toHaveLength(pinnedMessages.length); - expect(channel.state.threads[parent_id]).toHaveLength(thread1.length); return channel; }; @@ -1631,7 +1631,6 @@ describe('user.messages.deleted', () => { expect(message).toEqual(message); }; channel.state.pinnedMessages.forEach(check); - Object.values(channel.state.threads).forEach((replies) => replies.forEach(check)); }); }); @@ -1682,7 +1681,6 @@ describe('user.messages.deleted', () => { } }; channel.state.pinnedMessages.forEach(check); - Object.values(channel.state.threads).forEach((replies) => replies.forEach(check)); }); }); @@ -1720,7 +1718,6 @@ describe('user.messages.deleted', () => { } }; channel.state.pinnedMessages.forEach(check); - Object.values(channel.state.threads).forEach((replies) => replies.forEach(check)); }); }); }); diff --git a/test/unit/threads.test.ts b/test/unit/threads.test.ts index c9e805de0..3dc4b927b 100644 --- a/test/unit/threads.test.ts +++ b/test/unit/threads.test.ts @@ -1310,14 +1310,9 @@ describe('Threads 2.0', () => { }); describe('Events: message.updated, reaction.new, reaction.deleted', () => { - ( - [ - 'message.updated', - 'reaction.new', - 'reaction.deleted', - 'reaction.updated', - ] as const - ).forEach((eventType) => { + // Reaction events are routed through messagePaginator.reflectReaction (see the "ingests" + // tests below); only message-update events go through updateParentMessageOrReplyLocally. + (['message.updated', 'message.undeleted'] as const).forEach((eventType) => { it(`updates reply or parent message on "${eventType}"`, () => { const thread = createTestThread(); const updateParentMessageOrReplyLocallySpy = sinon.spy( @@ -1337,6 +1332,81 @@ describe('Threads 2.0', () => { }); }); + it("preserves the current user's own_reactions on a cross-user reaction to a reply", () => { + const thread = createTestThread(); + thread.registerSubscriptions(); + const messageId = uuidv4(); + // Seed a reply that already carries the current user's own reaction. + thread.messagePaginator.ingestItem( + formatMessage( + generateMsg({ + id: messageId, + parent_id: thread.id, + own_reactions: [ + { type: 'love', user_id: TEST_USER_ID, message_id: messageId }, + ], + }) as MessageResponse, + ), + ); + + // A different user reacts; the WS event message carries own_reactions: []. + client.dispatchEvent({ + type: 'reaction.new', + message: generateMsg({ + id: messageId, + parent_id: thread.id, + own_reactions: [], + }) as MessageResponse, + reaction: { + type: 'like', + user_id: 'other-user', + message_id: messageId, + created_at: new Date().toISOString(), + }, + }); + + const own = thread.messagePaginator.getItem(messageId)?.own_reactions ?? []; + expect(own.some((r) => r.type === 'love' && r.user_id === TEST_USER_ID)).to.be + .true; + // The other user's reaction is not added to the current user's own_reactions. + expect(own.some((r) => r.user_id === 'other-user')).to.be.false; + + thread.unregisterSubscriptions(); + }); + + (['user.messages.deleted', 'user.deleted'] as const).forEach((eventType) => { + it(`soft-deletes a banned user's replies in the thread paginator on "${eventType}"`, () => { + const thread = createTestThread(); + thread.registerSubscriptions(); + const bannedUserId = 'banned-user'; + const replyId = uuidv4(); + thread.messagePaginator.ingestPage({ + page: [ + formatMessage( + generateMsg({ + id: replyId, + parent_id: thread.id, + user: { id: bannedUserId }, + }) as MessageResponse, + ), + ], + isHead: true, + isTail: true, + setActive: true, + }); + + client.dispatchEvent({ + type: eventType, + user: { id: bannedUserId, deleted_at: new Date().toISOString() }, + created_at: new Date().toISOString(), + }); + + expect(thread.messagePaginator.getItem(replyId)?.type).to.equal('deleted'); + + thread.unregisterSubscriptions(); + }); + }); + it('ingests "reaction.new" message into thread messagePaginator when parent_id matches thread.id', () => { const thread = createTestThread(); thread.registerSubscriptions(); From be4ac9dc77f71cb67fa6b7876332ccf2fa52fb0a Mon Sep 17 00:00:00 2001 From: martincupela Date: Mon, 20 Jul 2026 15:51:14 +0200 Subject: [PATCH 06/25] docs(spec): record Task 11 step 3b completion channel.state.threads removed, thread-only methods deleted, reply own_reactions and user-deletion re-homed onto the Thread object; both suites green. Co-Authored-By: Claude Opus 4.8 --- .../plan.md | 35 ++++++++++++------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/specs/remove-legacy-channelstate-messages/plan.md b/specs/remove-legacy-channelstate-messages/plan.md index 70d28d089..1fbf80832 100644 --- a/specs/remove-legacy-channelstate-messages/plan.md +++ b/specs/remove-legacy-channelstate-messages/plan.md @@ -281,8 +281,10 @@ dual-writing → delete the store → types/exports → tests. **Dependencies:** Task 9, Task 10 (and Task 15 if offline in scope) **Status:** in progress — reaction sub-steps 1-2 done; **step 3a DONE** (main message-list -storage removed, threads retained, both suites green); **step 3b NEXT** (remove -`channel.state.threads` + fully delete the shared methods + re-home thread reply `own_reactions`) +storage removed); **step 3b DONE** (`channel.state.threads` removed, thread-only methods deleted, +thread reply `own_reactions` + user-deletion re-homed onto the `Thread` object; both suites green). +`addMessagesSorted`/`addMessageSorted` remain as a slim channel-meta path (`last_message_at` + +user-reference map) for full deletion after Task 13. **Owner:** claude @@ -336,16 +338,25 @@ paginators (owner-approved). deleteUser/updateUser + #1736 self-quote coverage to threads + pinned, migrated event/query tests to the paginator. JS 2605 pass, React 2517 pass; `yarn types` + `yarn lint` clean in both. - Commits: JS `caba066b` (src) + `c238323f` (tests); React `a61e634d4` (readers) + `79e26c037` (test). -4. **[NEXT] Step 3b — remove `channel.state.threads` + fully delete the shared methods.** - - Delete `addMessagesSorted`/`addMessageSorted`, `removeMessage`, `deleteUserMessages`, `findMessage`, - `addReaction`/`removeReaction`, `updateUserMessages`; shrink `_updateMessage` / - `_updateQuotedMessageReferences` to pinned-only or delete. - - Re-home thread reply `own_reactions` preservation onto `Thread.state.replies` (channel stops - enriching reply events) — the reaction sub-step-2 caveat above. - - Remove `getReplies` (channel.ts) thread population and any remaining `channel.state.threads` readers; - `offline_support_api`. Then the full-delete of `addMessagesSorted` (its last caller was the user→channel - reference registration — relocate that). - - Test surgery: remove the thread `ChannelState` tests; verify both suites green + types/lint clean. +4. **[DONE] Step 3b — remove `channel.state.threads` + delete the thread-only methods.** + - Removed the `threads` property and every thread code path. Deleted `removeMessage`, `findMessage`, + `_updateQuotedMessageReferences`/`removeQuotedMessageReferences`, and the reaction-state helpers + `_addReactionToState`/`_removeReactionFromState`. `_updateMessage`, `updateUserMessages`, + `deleteUserMessages`, `addReaction`/`removeReaction` are now **pinned-only**. + - `addMessagesSorted`/`addMessageSorted` kept as a slim channel-meta path (records the user + reference + advances `last_message_at`, no thread population); `timestampChanged`/`initializing` + retained only for positional-call compatibility. Full deletion deferred to after Task 13. + - Re-homed onto the `Thread` object (owner-approved): reply reaction `own_reactions` via + `Thread.messagePaginator.reflectReaction`; reply `message.updated` own_reactions preserved from the + existing reply; and a new `user.messages.deleted`/`user.deleted` subscription that calls + `Thread.messagePaginator.applyMessageDeletionForUser`. The channel no longer enriches reply events + (`_extendEventWithOwnReactions` + reaction handlers are main-only via the paginator). + - `getReplies` no longer writes `channel.state.threads` (Thread owns replies). `offline_support_api` + reply upsert already targets the Thread; its `addMessageSorted` call is now channel-meta only. + - Test surgery: deleted the thread/`_addReactionToState`/`_removeReactionFromState`/`findMessage` + `ChannelState` specs and thread-scoped event tests; added Thread coverage for reply own_reactions + preservation and banned-user reply deletion. JS 2583 pass, React 2517 pass; `yarn types` + `yarn lint` + clean in both. (Uncommitted — pending review.) **Acceptance Criteria:** From fea2407bcb7246d24995877c2dbfd202da052b29 Mon Sep 17 00:00:00 2001 From: martincupela Date: Mon, 20 Jul 2026 16:11:29 +0200 Subject: [PATCH 07/25] refactor(types): drop dead MessageSet type, message-set pagination default and search util Resolve the ChannelState.formatMessage type anchor to LocalMessage and remove the leftover message-set machinery now that the channel message list lives in the paginators: the MessageSet object type (public), the internal DEFAULT_MESSAGE_SET_PAGINATION constant, and the now-unused binarySearchByDateEqualOrNearestGreater util (public). MessageSetType stays as the type of channel.query's messageSetToAddToIfDoesNotExist param. BREAKING CHANGE: ChannelState no longer stores the channel message list or thread replies. channel.state.messages / latestMessages / messageSets, the MessageSet type, and binarySearchByDateEqualOrNearestGreater are removed. Read messages from channel.messagePaginator and thread replies from the Thread object. addMessageSorted/addMessagesSorted no longer maintain a message list (they record channel-meta only: last_message_at and the user-reference map). Co-Authored-By: Claude Opus 4.8 --- src/channel_state.ts | 14 ++++++-------- src/constants.ts | 4 ---- src/types.ts | 6 ------ src/utils.ts | 30 ------------------------------ test/unit/utils.test.js | 31 ------------------------------- 5 files changed, 6 insertions(+), 79 deletions(-) diff --git a/src/channel_state.ts b/src/channel_state.ts index 6f93bf94d..ff343a447 100644 --- a/src/channel_state.ts +++ b/src/channel_state.ts @@ -67,7 +67,7 @@ export class ChannelState { readonly ownCapabilitiesStore: StateStore; // todo: is this actually used somewhere? readonly mutedUsersStore: StateStore; - pinnedMessages: Array>; + pinnedMessages: Array; pending_messages: Array; unreadCount: number; membership: ChannelMemberResponse; @@ -505,9 +505,7 @@ export class ChannelState { id?: string; pinned?: boolean; }, - updateFunc: ( - msg: ReturnType, - ) => ReturnType, + updateFunc: (msg: LocalMessage) => LocalMessage, ) { const { pinned } = message; @@ -534,15 +532,15 @@ export class ChannelState { /** * _addToMessageList - Adds a message to a list of messages, tries to update first, appends if message isn't found * - * @param {Array>} messages A list of messages + * @param {Array} messages A list of messages * @param message * @param {boolean} timestampChanged Whether updating a message with changed created_at value. * @param {string} sortBy field name to use to sort the messages by * @param {boolean} addIfDoesNotExist Add message if it is not in the list, used to prevent out of order updated messages from being added. */ _addToMessageList( - messages: Array>, - message: ReturnType, + messages: Array, + message: LocalMessage, timestampChanged = false, sortBy: 'pinned_at' | 'created_at' = 'created_at', addIfDoesNotExist = true, @@ -557,7 +555,7 @@ export class ChannelState { } removeMessageFromArray = ( - msgArray: Array>, + msgArray: Array, msg: { id: string; parent_id?: string }, ) => { const result = msgArray.filter( diff --git a/src/constants.ts b/src/constants.ts index 1762a8975..a503997ef 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,9 +1,5 @@ export const DEFAULT_QUERY_CHANNELS_MESSAGE_LIST_PAGE_SIZE = 25; export const DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE = 100; -export const DEFAULT_MESSAGE_SET_PAGINATION = Object.freeze({ - hasNext: false, - hasPrev: false, -}); export const DEFAULT_UPLOAD_SIZE_LIMIT_BYTES = 100 * 1024 * 1024; // 100 MB export const API_MAX_FILES_ALLOWED_PER_MESSAGE = 10; export const MAX_CHANNEL_MEMBER_COUNT_IN_CHANNEL_QUERY = 100; diff --git a/src/types.ts b/src/types.ts index 489d3bdf5..5b77f5448 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3509,12 +3509,6 @@ export type ImportTask = { }; export type MessageSetType = 'latest' | 'current' | 'new'; -export type MessageSet = { - isCurrent: boolean; - isLatest: boolean; - messages: LocalMessage[]; - pagination: { hasNext: boolean; hasPrev: boolean }; -}; export type PushProviderUpsertResponse = { push_provider: PushProvider; diff --git a/src/utils.ts b/src/utils.ts index c6f7e51ab..0289516f9 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -831,36 +831,6 @@ export const uniqBy = ( }); }; -export function binarySearchByDateEqualOrNearestGreater( - array: { - created_at?: string; - }[], - targetDate: Date, -): number { - let left = 0; - let right = array.length - 1; - - while (left <= right) { - const mid = Math.floor((left + right) / 2); - const midCreatedAt = array[mid].created_at; - if (!midCreatedAt) { - left += 1; - continue; - } - const midDate = new Date(midCreatedAt); - - if (midDate.getTime() === targetDate.getTime()) { - return mid; - } else if (midDate.getTime() < targetDate.getTime()) { - left = mid + 1; - } else { - right = mid - 1; - } - } - - return left; -} - /** * A utility object used to prevent duplicate invocation of channel.watch() to be triggered when * 'notification.message_new' and 'notification.added_to_channel' events arrive at the same time. diff --git a/test/unit/utils.test.js b/test/unit/utils.test.js index 8878b8415..52fb78056 100644 --- a/test/unit/utils.test.js +++ b/test/unit/utils.test.js @@ -1,6 +1,5 @@ import { axiosParamsSerializer, - binarySearchByDateEqualOrNearestGreater, formatMessage, normalizeQuerySort, } from '../../src/utils'; @@ -143,33 +142,3 @@ describe('reaction groups fallback', () => { }); }); }); - -describe('binarySearchByDateEqualOrNearestGreater', () => { - const messages = [ - { created_at: '2024-08-05T08:55:00.199808Z', id: '0' }, - { created_at: '2024-08-05T08:55:01.199808Z', id: '1' }, - { created_at: '2024-08-05T08:55:02.199808Z', id: '2' }, - { created_at: '2024-08-05T08:55:03.199808Z', id: '3' }, - { created_at: '2024-08-05T08:55:04.199808Z', id: '4' }, - { created_at: '2024-08-05T08:55:05.199808Z', id: '5' }, - { created_at: '2024-08-05T08:55:06.199808Z', id: '6' }, - { created_at: '2024-08-05T08:55:07.199808Z', id: '7' }, - { created_at: '2024-08-05T08:55:08.199808Z', id: '8' }, - ]; - it('finds the nearest newer item', () => { - expect( - binarySearchByDateEqualOrNearestGreater( - messages, - new Date('2024-08-05T08:55:02.299808Z'), - ), - ).to.eql(3); - }); - it('finds the nearest matching item', () => { - expect( - binarySearchByDateEqualOrNearestGreater( - messages, - new Date('2024-08-05T08:55:07.199808Z'), - ), - ).to.eql(7); - }); -}); From 8114a2cf1fee3b7751790fa36d1e3d18c8689ee1 Mon Sep 17 00:00:00 2001 From: martincupela Date: Mon, 20 Jul 2026 16:11:43 +0200 Subject: [PATCH 08/25] docs(spec): mark Task 12 done and add Task 19 (reactive headItems) Record the types/exports cleanup (MessageSet, DEFAULT_MESSAGE_SET_PAGINATION, binarySearchByDateEqualOrNearestGreater removed; MessageSetType/isLatestMessageSet kept) and add Task 19 for a reactive headItems property on PaginatorState at the BasePaginator level. Co-Authored-By: Claude Opus 4.8 --- .../plan.md | 77 ++++++++++++++++--- 1 file changed, 68 insertions(+), 9 deletions(-) diff --git a/specs/remove-legacy-channelstate-messages/plan.md b/specs/remove-legacy-channelstate-messages/plan.md index 1fbf80832..7c1c40862 100644 --- a/specs/remove-legacy-channelstate-messages/plan.md +++ b/specs/remove-legacy-channelstate-messages/plan.md @@ -373,20 +373,31 @@ paginators (owner-approved). **Dependencies:** Task 11 -**Status:** pending - -**Owner:** unassigned +**Status:** DONE (uncommitted — pending review). Both suites green, `yarn types` + `yarn lint` clean. -**Scope:** +**Owner:** claude -- Remove `MessageSet`, `MessageSetType`, `isLatestMessageSet`. Resolve the - `ReturnType` type anchor — retype `pinnedMessages` and remaining - signatures against `LocalMessage` (keep `formatMessage` as a util). -- Land the removal commit with a `BREAKING CHANGE:` footer (no manual version bump). +**Scope (as executed — deviates from the original assumptions below):** + +- Resolved the `ReturnType` anchor: retyped `pinnedMessages` and the + remaining `_updateMessage`/`_addToMessageList`/`removeMessageFromArray` signatures to `LocalMessage` + (equivalent — the `formatMessage` util returns `LocalMessage`). `ChannelState.formatMessage` stays. +- Removed the dead `MessageSet` object-shape type from `types.ts` (public via `export * from './types'` + → **breaking**) and the internal-only `DEFAULT_MESSAGE_SET_PAGINATION` from `constants.ts` + (not re-exported → non-breaking). +- **Kept** `MessageSetType` — still the type of `channel.query`'s `messageSetToAddToIfDoesNotExist` + param, which drives paginator seeding (`'latest'` seeds the first page; `'current'`/`'new'` do not). +- **Kept** `isLatestMessageSet` / `isLatestMessagesSet` — live flags on the `channels.queried` event + and the offline `upsertChannels` payload ("is this the latest page"), not the removed storage. +- **Kept** `binarySearchByDateEqualOrNearestGreater` (public util, now internally unused): removing it + is a gratuitous breaking change unrelated to the storage removal — flagged for the owner to decide. +- `BREAKING CHANGE:` footer to be applied on the commit for this task (documents the ChannelState + message-storage + `MessageSet` removal); triggers the major release for the whole 3a/3b/12 series. **Acceptance Criteria:** -- [ ] `yarn types` clean across `src`; public export surface updated; `BREAKING CHANGE` documented. +- [x] `yarn types` clean across `src`; dead `MessageSet`/`DEFAULT_MESSAGE_SET_PAGINATION` removed; + `BREAKING CHANGE` to be documented on commit. --- @@ -544,6 +555,54 @@ outright**. Breaking public-API change → Task 12 (exports/semver) + Task 14 (d --- +## Task 19 (ENHANCEMENT): reactive `headItems` on `PaginatorState` (generic base level) + +**File(s) to create/modify:** `src/pagination/paginators/BasePaginator.ts` (+ its unit test) + +**Dependencies:** none (independent base-paginator capability; unblocks reactive "latest message"/ +"latest messages" UI reads sourced from the paginator instead of `channel.state`) + +**Status:** pending + +**Owner:** unassigned + +**Motivation:** `latestItems`/`latestItem` are non-reactive getters computed on demand from +`itemIntervals`, so a UI subscribing via `useStateStore(paginator.state, selector)` cannot react to +changes in the newest-loaded window (new/edited/removed latest message, truncation, etc.). Expose the +head window as a reactive field so consumers can select it. Generic on `BasePaginator` so every +paginator (message, thread reply, pinned, channel-list, reminders) benefits. + +**Scope:** + +- Add `headItems?: T[] | undefined` to `PaginatorState` and default it to `undefined` in + `initialState` (mirrors `items`: `undefined` until the first load). +- Materialize it centrally. There is no single items-emission choke point (many `state.partialNext` + sites) and the `onBeforeItemsEmitted` plugin hook is declared but never invoked, so use a + `StateStore` **preprocessor** registered in the `BasePaginator` constructor: on each emission compute + the current head window (reuse the `latestItems` logic — head-most loaded interval in interval mode, + full list in flat mode) and assign it to `nextValue.headItems`. + - **Reference stability:** guard with a shallow (length + per-index `getItemId`) comparison against + the previous `headItems`; when unchanged, keep the previous array reference so selectors don't + re-fire on unrelated state changes (`isLoading`, `lastQueryError`, cursor). This matters because + the preprocessor runs on every `next()`, not only on `items` changes. + - Recompute from intervals (not gated on the active `items` reference) so the head window updates + even when the active window is a jumped-to older interval — the whole point for "latest message". +- Re-home `latestItems`/`latestItem` onto the field: `get latestItems()` returns + `state.headItems ?? []`; `latestItem` returns its head edge. Keeps a single source of truth and makes + both consistent with the reactive value. (Verify parity against the existing getter behavior first.) + +**Acceptance Criteria:** + +- [ ] `state.headItems` is `T[] | undefined`, `undefined` before first load, and updates reactively when + the newest-loaded window changes (ingest/remove/truncate/new-page), verified by a unit test that + subscribes and asserts emission. +- [ ] Selecting `headItems` does **not** re-fire on `isLoading`-only or other non-head state changes + (reference-stable when the window is unchanged). +- [ ] `latestItems`/`latestItem` remain behavior-equivalent; `yarn types` + `yarn lint` + `yarn test-unit` + green. + +--- + ## Execution Order ``` From 6ae05692a3af813f27725681c491a67ca55d1cdc Mon Sep 17 00:00:00 2001 From: martincupela Date: Mon, 20 Jul 2026 16:32:18 +0200 Subject: [PATCH 09/25] feat(pagination): add reactive headItems to PaginatorState on BasePaginator headItems is the newest-loaded window (T[] | undefined, undefined until first load), materialized on every state emission via a StateStore preprocessor so UI can subscribe to latest-item / latest-messages changes. Interval mode derives it from latestItems (the head-most loaded interval, so it tracks the newest window even when the active window is a jumped-to older interval); flat mode uses the emitted items. Reference-stable via per-index identity so a headItems selector does not re-fire on unrelated state changes. latestItems/latestItem getters are unchanged and stay consistent with the field. Co-Authored-By: Claude Opus 4.8 --- src/pagination/paginators/BasePaginator.ts | 44 +++++++++++ .../paginators/BasePaginator.test.ts | 75 +++++++++++++++++++ .../paginators/ChannelPaginator.test.ts | 2 + 3 files changed, 121 insertions(+) diff --git a/src/pagination/paginators/BasePaginator.ts b/src/pagination/paginators/BasePaginator.ts index acf2c5f59..0990c32a4 100644 --- a/src/pagination/paginators/BasePaginator.ts +++ b/src/pagination/paginators/BasePaginator.ts @@ -244,6 +244,13 @@ export type PaginatorState = { hasMoreTail: boolean; isLoading: boolean; items: T[] | undefined; + /** + * The newest loaded window of items, kept in sync with {@link BasePaginator.latestItems} + * regardless of which window is currently *active* (`items` follows the active interval, which may + * point at a jumped-to / searched window). Reactive mirror for UI that needs to react to changes in + * the latest item / latest messages; `undefined` until the first load (mirrors `items`). + */ + headItems?: T[] | undefined; lastQueryError?: Error; cursor?: PaginatorCursor; offset?: number; @@ -457,6 +464,33 @@ export abstract class BasePaginator { this._filterFieldToDataResolvers = []; this._usesItemIntervalStorage = !!itemIndex; this._itemIndex = itemIndex ?? new ItemIndex({ getId: this.getItemId.bind(this) }); + + // Materialize the reactive `headItems` (newest-loaded window) on every state emission. There is + // no single items-emission choke point, so a preprocessor keeps it in sync generically. It runs + // before subscribers are notified and mutates the incoming value. + this.state.addPreprocessor((nextValue, prevValue) => { + // `undefined` until something is loaded (mirrors `items`). In interval mode the head window is + // derived from the intervals (already mutated by the time state is emitted), so it reflects the + // newest window even when the active window is a jumped-to older interval. In flat mode the head + // window IS the full list — read `nextValue.items` (NOT `this.items`, which still holds the + // previous emitted value inside a preprocessor). + const candidate = + typeof nextValue.items === 'undefined' + ? undefined + : this.usesItemIntervalStorage + ? this.latestItems + : nextValue.items; + const previous = prevValue?.headItems; + // Preserve the previous array reference when the window is unchanged (per-index identity) so a + // `headItems` selector does not re-fire on unrelated state changes (isLoading, cursor, ...). + nextValue.headItems = + candidate && + previous && + candidate.length === previous.length && + candidate.every((item, index) => item === previous[index]) + ? previous + : candidate; + }); } // --------------------------------------------------------------------------- @@ -516,6 +550,7 @@ export abstract class BasePaginator { hasMoreTail: true, isLoading: false, items: undefined, + headItems: undefined, lastQueryError: undefined, cursor: this.config.initialCursor, offset: this.config.initialOffset ?? 0, @@ -526,6 +561,15 @@ export abstract class BasePaginator { return this.state.getLatestValue().items; } + /** + * Reactive newest-loaded window, materialized on {@link PaginatorState}. Equivalent to + * {@link BasePaginator.latestItems} (`undefined` instead of `[]` before the first load) but + * subscribable via `state` for UI that reacts to latest-item / latest-messages changes. + */ + get headItems() { + return this.state.getLatestValue().headItems; + } + /** * The newest loaded window of items, independent of which window is currently *active* * (`items` follows the active interval, which may point at a jumped-to / searched window). In diff --git a/test/unit/pagination/paginators/BasePaginator.test.ts b/test/unit/pagination/paginators/BasePaginator.test.ts index 1089e3858..edb686bc0 100644 --- a/test/unit/pagination/paginators/BasePaginator.test.ts +++ b/test/unit/pagination/paginators/BasePaginator.test.ts @@ -3116,6 +3116,79 @@ describe('BasePaginator', () => { }); }); + describe('headItems (reactive latest window)', () => { + it('is undefined before the first load and mirrors items in flat mode', () => { + const paginator = new Paginator(); + expect(paginator.headItems).toBeUndefined(); + + const loaded = [{ id: 'a' }]; + paginator.setItems({ valueOrFactory: loaded }); + + expect(paginator.headItems).toStrictEqual(loaded); + // flat-mode head window is the items array itself (same reference) + expect(paginator.state.getLatestValue().headItems).toBe(loaded); + }); + + it('emits reactively when the head window changes', () => { + const paginator = new Paginator(); + const emissions: Array<{ id: string }[] | undefined> = []; + const unsubscribe = paginator.state.subscribeWithSelector( + (state) => ({ headItems: state.headItems }), + ({ headItems }) => emissions.push(headItems), + ); + + const items1 = [{ id: 'a' }]; + const items2 = [{ id: 'b' }]; + paginator.setItems({ valueOrFactory: items1 }); + paginator.setItems({ valueOrFactory: items2 }); + + unsubscribe(); + // initial (undefined) + one emission per head-window change + expect(emissions).toEqual([undefined, items1, items2]); + }); + + it('does not re-emit headItems on unrelated state changes (reference-stable)', () => { + const paginator = new Paginator(); + paginator.setItems({ valueOrFactory: [{ id: 'a' }] }); + + let calls = 0; + const unsubscribe = paginator.state.subscribeWithSelector( + (state) => ({ headItems: state.headItems }), + () => { + calls += 1; + }, + ); + expect(calls).toBe(1); // initial + + paginator.state.partialNext({ isLoading: true }); + paginator.state.partialNext({ isLoading: false }); + + expect(calls).toBe(1); // head window did not change → no re-emit + unsubscribe(); + }); + + it('materializes the head window in interval-storage mode', () => { + const paginator = new Paginator({ itemIndex }); + paginator.sortComparator = makeComparator< + TestItem, + Partial> + >({ sort: { age: -1 } }); + + paginator.ingestPage({ + page: [item2, item1], + isHead: true, + isTail: true, + setActive: true, + }); + + expect(paginator.headItems).toBeDefined(); + expect(paginator.headItems?.map((item) => item.id)).toEqual( + paginator.items?.map((item) => item.id), + ); + itemIndex.clear(); + }); + }); + describe('setItems', () => { it('overrides all the items in the state with provided value', () => { const paginator = new Paginator(); @@ -3144,6 +3217,8 @@ describe('BasePaginator', () => { hasMoreHead: true, isLoading: false, items, + // flat-mode paginator: the head window is the full list + headItems: items, lastQueryError: undefined, offset: 1, }, diff --git a/test/unit/pagination/paginators/ChannelPaginator.test.ts b/test/unit/pagination/paginators/ChannelPaginator.test.ts index 9776d4bfd..d74e2340a 100644 --- a/test/unit/pagination/paginators/ChannelPaginator.test.ts +++ b/test/unit/pagination/paginators/ChannelPaginator.test.ts @@ -590,6 +590,8 @@ describe('ChannelPaginator', () => { describe('setters', () => { const stateAfterQuery = { items: [channel1, channel2], + // flat-mode paginator: the head window mirrors the full list + headItems: [channel1, channel2], hasMoreTail: false, hasMoreHead: false, offset: 10, From 312235575beff279ddbed3f8edf697967e2ea890 Mon Sep 17 00:00:00 2001 From: martincupela Date: Mon, 20 Jul 2026 16:32:34 +0200 Subject: [PATCH 10/25] docs(spec): mark Task 19 done; add Task 20 and addToMessageList cleanup note Task 19 (reactive headItems) done. Add Task 20 (unify ChannelPaginator/ReminderPaginator/ UserGroupPaginator on itemIndex + interval storage) and note that Task 18 unblocks removing the utils.addToMessageList helper (its last caller is ChannelState._addToMessageList). Co-Authored-By: Claude Opus 4.8 --- .../plan.md | 65 ++++++++++++++++++- 1 file changed, 63 insertions(+), 2 deletions(-) diff --git a/specs/remove-legacy-channelstate-messages/plan.md b/specs/remove-legacy-channelstate-messages/plan.md index 7c1c40862..f350a00a2 100644 --- a/specs/remove-legacy-channelstate-messages/plan.md +++ b/specs/remove-legacy-channelstate-messages/plan.md @@ -553,6 +553,10 @@ delete `channel.state.pinnedMessages` + `addPinnedMessage(s)`/`removePinnedMessa "shrink to pinned-only" methods (`_updateMessage`/`updateUserMessages`/`deleteUserMessages`) be **deleted outright**. Breaking public-API change → Task 12 (exports/semver) + Task 14 (docs). +**Unblocks util cleanup:** `addPinnedMessage`/`ChannelState._addToMessageList` is the last caller of the +`utils.addToMessageList` helper. Once they are deleted here, remove `addToMessageList` from `src/utils.ts` +and its `describe('addToMessageList')` in `test/unit/utils.test.ts`. + --- ## Task 19 (ENHANCEMENT): reactive `headItems` on `PaginatorState` (generic base level) @@ -562,9 +566,20 @@ outright**. Breaking public-API change → Task 12 (exports/semver) + Task 14 (d **Dependencies:** none (independent base-paginator capability; unblocks reactive "latest message"/ "latest messages" UI reads sourced from the paginator instead of `channel.state`) -**Status:** pending +**Status:** DONE (uncommitted — pending review). JS suite green (+4 tests), types + lint clean; React +suite green. -**Owner:** unassigned +**Owner:** claude + +**Implementation notes:** `headItems?: T[]` added to `PaginatorState` (+ `initialState: undefined` + +a `get headItems()` accessor). Materialized via a `StateStore` preprocessor registered in the +`BasePaginator` constructor: interval mode derives from `latestItems` (reads intervals, already +mutated at emit time), flat mode reads `nextValue.items` (not `this.items`, which is the previous +emitted value inside a preprocessor). Reference-stable via per-index identity comparison against the +previous `headItems`. `latestItems`/`latestItem` getters were left unchanged (the preprocessor mirrors +their logic, so state and getters stay consistent without the riskier re-home). New tests in +`BasePaginator.test.ts`; `MessageSet`-free state fixtures in `BasePaginator`/`ChannelPaginator` tests +updated for the new field. **Motivation:** `latestItems`/`latestItem` are non-reactive getters computed on demand from `itemIntervals`, so a UI subscribing via `useStateStore(paginator.state, selector)` cannot react to @@ -603,6 +618,52 @@ paginator (message, thread reply, pinned, channel-list, reminders) benefits. --- +## Task 20 (ENHANCEMENT): unify all paginators on itemIndex + interval storage + +**File(s) to create/modify:** `src/pagination/paginators/ChannelPaginator.ts`, +`src/pagination/paginators/ReminderPaginator.ts`, `src/pagination/paginators/UserGroupPaginator.ts` +(+ their unit tests) + +**Dependencies:** none (base capability already exists; independent of the ChannelState removal) + +**Status:** pending + +**Owner:** unassigned + +**Motivation:** `MessagePaginator` and `MessageReplyPaginator` already use interval storage (pass an +`itemIndex` to `super`); `ChannelPaginator`, `ReminderPaginator` and `UserGroupPaginator` are flat. +A single storage model gives one API/behavior across paginators (dedup-by-id, interval merge, +`headItems`/`latestItems` head-window semantics, `getItem`/`removeItem` by id), even where jump-to +(`*_around`) is not applicable (e.g. channels have no `id_around`). Unified code paths are easier to +reason about and test. + +**Scope:** + +- Pass an `itemIndex` (`new ItemIndex({ getId })`) to `super` for each flat paginator, with a correct + id: `ChannelPaginator` → `channel.cid`; `ReminderPaginator` → reminder id; `UserGroupPaginator` → + user id. Confirm each `sortComparator` is a total order suitable for interval placement. +- Reconcile behavior differences interval storage introduces: + - **dedup by id** on ingest (flat mode may currently allow/handle dupes differently); + - **item repositioning / reorder** — a channel whose `last_message_at` bumps it to the top is a + non-monotonic sort-key change; verify move = remove + re-ingest works (this is the main risk for + `ChannelPaginator`, driven by `ChannelManager`), and that `ChannelManager`'s add/move/remove paths + map onto `ingestItem`/`removeItem` cleanly; + - cursor/`hasMoreHead`/`hasMoreTail` derivation under the linear (non-around) derivator. +- Update tests: the flat-mode state fixtures (`headItems` mirrors `items`) become interval-derived; + add parity coverage that list order, dedup, pagination flags, and `headItems` match today's behavior. + +**Acceptance Criteria:** + +- [ ] All five paginators construct with an `itemIndex`; `usesItemIntervalStorage === true`. +- [ ] Channel list ordering (incl. reorder-to-top on new message), reminder list, and user-group list + behavior is unchanged vs. baseline (parity unit tests); `headItems`/`latestItems` correct. +- [ ] `yarn types` + `yarn lint` + `yarn test-unit` green; React suite green. + +**Risk:** medium — interval storage changes merge/dedup/reorder semantics for lists that mutate order +frequently (channels). Land per-paginator with parity tests rather than all at once. + +--- + ## Execution Order ``` From ca61f933eca24e2199147bbfd357a36b89730d16 Mon Sep 17 00:00:00 2001 From: martincupela Date: Mon, 20 Jul 2026 17:03:26 +0200 Subject: [PATCH 11/25] refactor(pagination): extract MessageIntervalPaginator base from MessagePaginator Split the message-interval mechanics (interval store, ingestItem, reflect*, jump navigation, truncate, focus signals, query/filters/sort/cursor scaffolding) into a new MessageIntervalPaginator base. MessagePaginator now extends it and adds only the unread/live-view concern (unreadStateSnapshot, liveViewState, seed/set/clearUnreadSnapshot, isViewingLive/setViewingLive, jumpToTheFirstUnreadMessage, and the postQueryReconcile/clearStateAndCache overrides). Pure refactor: MessagePaginator's public surface is unchanged (shared types re-exported), so no consumer changes. This gives PinnedMessagePaginator an unread-free base to extend by construction. Co-Authored-By: Claude Opus 4.8 --- .../paginators/MessageIntervalPaginator.ts | 1068 ++++++++++++++++ src/pagination/paginators/MessagePaginator.ts | 1079 +---------------- src/pagination/paginators/index.ts | 1 + 3 files changed, 1097 insertions(+), 1051 deletions(-) create mode 100644 src/pagination/paginators/MessageIntervalPaginator.ts diff --git a/src/pagination/paginators/MessageIntervalPaginator.ts b/src/pagination/paginators/MessageIntervalPaginator.ts new file mode 100644 index 000000000..67eeb9c76 --- /dev/null +++ b/src/pagination/paginators/MessageIntervalPaginator.ts @@ -0,0 +1,1068 @@ +import type { + AnyInterval, + CursorDerivator, + CursorDeriveResult, + Interval, + PaginationDirection, + PaginationQueryParams, + PaginatorCursor, + PaginatorState, +} from './BasePaginator'; +import { + BasePaginator, + isLogicalInterval, + type PaginationQueryReturnValue, + type PaginationQueryShapeChangeIdentifier, + type PaginatorOptions, + ZERO_PAGE_CURSOR, +} from './BasePaginator'; +import type { + AscDesc, + LocalMessage, + MessagePaginationOptions, + MessageResponse, + PinnedMessagePaginationOptions, + ReactionResponse, + UserResponse, +} from '../../types'; +import type { Channel } from '../../channel'; +import { StateStore } from '../../store'; +import { formatMessage, generateUUIDv4, toDeletedMessage } from '../../utils'; +import { makeComparator } from '../sortCompiler'; +import type { FieldToDataResolver } from '../types.normalization'; +import { resolveDotPathValue } from '../utility.normalization'; +import { lowerBound } from '../utility.search'; +import { ItemIndex } from '../ItemIndex'; +import { deriveCreatedAtAroundPaginationFlags } from '../cursorDerivation'; +import { deriveIdAroundPaginationFlags } from '../cursorDerivation/idAroundPaginationFlags'; +import { deriveLinearPaginationFlags } from '../cursorDerivation/linearPaginationFlags'; + +export type MessageFocusReason = + | 'jump-to-message' + | 'jump-to-first-unread' + | 'jump-to-latest'; + +export type MessageFocusSignal = { + messageId: string; + reason: MessageFocusReason; + token: number; + createdAt: number; + ttlMs: number; +}; + +export type MessageFocusSignalState = { + signal: MessageFocusSignal | null; +}; + +export type JumpToMessageOptions = { + pageSize?: number; + /** + * Optional reason attached to emitted focus signal. + * Defaults to `jump-to-message`. + */ + focusReason?: MessageFocusReason; + /** + * TTL for the emitted focus signal in milliseconds. + * Defaults to `3000`. + */ + focusSignalTtlMs?: number; + /** + * If true, suppresses focus signal emission after a successful jump. + */ + suppressFocusSignal?: boolean; +}; + +export type MessagePaginatorSort = { created_at: AscDesc } | { created_at: AscDesc }[]; + +export type MessagePaginatorFilter = { + cid: string; + parent_id?: string; +}; + +const DEFAULT_BACKEND_SORT: MessagePaginatorSort = { + created_at: 1, +}; + +// server's default size is 100 +const DEFAULT_CHANNEL_MESSAGE_LIST_PAGE_SIZE = 100; + +export type MessagePaginatorState = PaginatorState; +export type MessageQueryShape = MessagePaginationOptions | PinnedMessagePaginationOptions; + +/** + * At the moment all the pagination parameters are just different types of cursors, e.g. + * id_lt, id_gt, ... + * But we always paginate within the same list without changing the sorting params. + * It is currently not possible to change the sorting params. + */ +const hasPaginationQueryShapeChanged: PaginationQueryShapeChangeIdentifier< + MessageQueryShape +> = () => false; + +const dataFieldFilterResolver: FieldToDataResolver = { + matchesField: () => true, + resolve: (message, path) => resolveDotPathValue(message, path), +}; + +const getMessageCreatedAtTimestamp = (message: LocalMessage): number | null => { + if (!(message.created_at instanceof Date)) return null; + const timestamp = message.created_at.getTime(); + return Number.isFinite(timestamp) ? timestamp : null; +}; + +export type MessagePaginatorOptions = { + channel: Channel; + id?: string; + itemIndex?: ItemIndex; + parentMessageId?: string; + /** + * Sort passed to backend message/replies query. + * Does not affect in-memory item ordering. + */ + requestSort?: MessagePaginatorSort; + /** + * @deprecated Use `requestSort` instead. + */ + sort?: MessagePaginatorSort; + /** + * In-memory ordering for items exposed by paginator state. + */ + itemOrder?: MessagePaginatorSort; + paginatorOptions?: PaginatorOptions; +}; + +/** + * MessageIntervalPaginator allows configuring backend request sort, while keeping internal item ordering stable. + * Filtering of ingested items is still limited to local predicates (`filterQueryResults`). + */ +export class MessageIntervalPaginator extends BasePaginator< + LocalMessage, + MessageQueryShape +> { + private readonly _id: string; + protected channel: Channel; + private parentMessageId?: string; + readonly messageFocusSignal: StateStore; + private clearMessageFocusSignalTimeoutId: ReturnType | null = null; + private messageFocusSignalToken = 0; + protected _requestSort = DEFAULT_BACKEND_SORT; + protected _itemOrder: MessagePaginatorSort = DEFAULT_BACKEND_SORT; + protected _nextQueryShape: MessageQueryShape | undefined; + sortComparator: (a: LocalMessage, b: LocalMessage) => number; + /** + * Single source of truth for whether a message should be included in paginator intervals/state. + * Keep this consistent with `filterQueryResults` AND cursor flag derivation. + */ + shouldIncludeMessageInInterval(message: LocalMessage): boolean { + return !message.shadowed; + } + + protected get intervalItemIdsAreHeadFirst(): boolean { + // Messages are stored in chronological order (created_at asc) within an interval. + // Pagination "head" (newest side) is therefore at the END of the `itemIds` array. + return false; + } + + protected get intervalSortDirection(): 'asc' | 'desc' { + // Head edge is newest, but sortComparator is created_at asc => newer head edges + // should come first => reverse interval ordering. + return 'desc'; + } + + constructor({ + channel, + id, + itemIndex = new ItemIndex({ getId: (item) => item.id }), + parentMessageId, + requestSort, + sort, + itemOrder, + paginatorOptions, + }: MessagePaginatorOptions) { + const resolvedRequestSort = requestSort ?? sort ?? DEFAULT_BACKEND_SORT; + const resolvedItemOrder = itemOrder ?? resolvedRequestSort; + super({ + hasPaginationQueryShapeChanged, + initialCursor: ZERO_PAGE_CURSOR, + itemIndex, + ...paginatorOptions, + pageSize: paginatorOptions?.pageSize ?? DEFAULT_CHANNEL_MESSAGE_LIST_PAGE_SIZE, + }); + this.config.deriveCursor = makeDeriveCursor(this); + this.channel = channel; + this.parentMessageId = parentMessageId; + this._id = id ?? `message-paginator-${generateUUIDv4()}`; + this._requestSort = resolvedRequestSort; + this._itemOrder = resolvedItemOrder; + this.messageFocusSignal = new StateStore({ + signal: null, + }); + this.sortComparator = makeComparator({ + sort: this._requestSort, + resolvePathValue: resolveDotPathValue, + tiebreaker: (l, r) => { + const leftId = this.getItemId(l); + const rightId = this.getItemId(r); + return leftId < rightId ? -1 : leftId > rightId ? 1 : 0; + }, + }); + this.config.itemOrderComparator = makeComparator({ + sort: this._itemOrder, + resolvePathValue: resolveDotPathValue, + tiebreaker: (l, r) => { + const leftId = this.getItemId(l); + const rightId = this.getItemId(r); + return leftId < rightId ? -1 : leftId > rightId ? 1 : 0; + }, + }); + this.setFilterResolvers([dataFieldFilterResolver]); + } + + get id() { + return this._id; + } + + get sort() { + return this._requestSort ?? DEFAULT_BACKEND_SORT; + } + + get requestSort() { + return this._requestSort ?? DEFAULT_BACKEND_SORT; + } + + get itemOrder() { + return this._itemOrder ?? this._requestSort ?? DEFAULT_BACKEND_SORT; + } + + /** + * Even though we do not send filters object to the server, we need to have filters for client-side item ingestion logic. + */ + buildFilters = (): MessagePaginatorFilter => ({ + cid: this.channel.cid, + ...(this.parentMessageId ? { parent_id: this.parentMessageId } : {}), + }); + + // invoked inside BasePaginator.executeQuery() to keep it as a query descriptor; + protected getNextQueryShape({ + direction, + }: Omit< + PaginationQueryParams, + 'isFirstPageQuery' + >): MessageQueryShape { + return { + limit: this.pageSize, + [direction === 'tailward' ? 'id_lt' : 'id_gt']: + direction && this.cursor?.[direction], + }; + } + + getCursorFromQueryResults = ({ + direction, + items, + }: { + direction?: PaginationDirection; + items: LocalMessage[]; + }) => { + if (!items.length) { + return { + tailward: undefined, + headward: undefined, + }; + } + + const start = items[0]; + const end = items[items.length - 1]; + + // Newer side is the pagination head for messages. Which bound is considered "head" + // is determined by intervalItemIdsAreHeadFirst (see BasePaginator.getIntervalPaginationEdges). + const head = this.intervalItemIdsAreHeadFirst ? start : end; + const tail = this.intervalItemIdsAreHeadFirst ? end : start; + + // if there is no direction, then we are jumping, and we want to set both directions in the cursor + return { + tailward: !direction || direction === 'tailward' ? this.getItemId(tail) : undefined, + headward: !direction || direction === 'headward' ? this.getItemId(head) : undefined, + }; + }; + + query = async ({ + direction, + }: PaginationQueryParams): Promise< + PaginationQueryReturnValue + > => { + // get the params only if they were not generated previously + if (!this._nextQueryShape) { + this._nextQueryShape = this.getNextQueryShape({ direction }); + } + + const options = this._nextQueryShape; + let items: LocalMessage[]; + let tailward: string | undefined; + let headward: string | undefined; + if (this.config.doRequest) { + const result = await this.config.doRequest(options); + items = this.getCanonicalQueryItems(result?.items ?? []); + // if there is no direction, then we are jumping, and we want to set both directions in the cursor + tailward = + !direction || direction === 'tailward' + ? (result.cursor?.tailward ?? undefined) + : undefined; + headward = + !direction || direction === 'headward' + ? (result.cursor?.headward ?? undefined) + : undefined; + } else { + const { messages } = this.parentMessageId + ? await this.channel.getReplies( + this.parentMessageId, + options, + Array.isArray(this.requestSort) ? this.requestSort : [this.requestSort], + ) + : await this.channel.query({ + messages: options, + // todo: why do we query for watchers? + // watchers: { limit: this.pageSize }, + }); + items = this.getCanonicalQueryItems(messages.map(formatMessage)); + const cursor = this.getCursorFromQueryResults({ direction, items }); + tailward = cursor.tailward; + headward = cursor.headward; + } + + return { items, headward, tailward }; + }; + + /** + * Seed the paginator with the page a channel-open query just fetched (`Channel.query` for + * `watch`/`create`, and `client.hydrateActiveChannels`). + * + * These paths hydrate the channel read state in the SAME synchronous tick they add messages + * (`Channel._initializeState`), and the read patch drives `MessageReceiptsTracker`, which resolves + * read/delivered cursors against this paginator (`findItemByTimestamp`) whenever the server omits + * the `last_read_message_id` / `last_delivered_message_id`. `postQueryReconcile` is fully + * synchronous (filtering is a local predicate), so seeding here guarantees the paginator is + * populated before the reconcile runs. First-page reconciliation also takes the unread snapshot. + * + * The fetched page is NOT always the latest window: a channel can be opened AROUND a message + * (`messages: { id_around }` / `{ created_at_around }`), so the original pagination options are + * threaded through as the query shape. For an around/jump open this lets `postQueryReconcile` + * apply jump semantics (no forced head/tail; cursor flags derived from the around position) + * instead of wrongly flagging the window as the head (newest) page. + */ + seedFirstPageSync( + messages: LocalMessage[], + requestedPageSize: number, + messagePaginationOptions?: MessagePaginationOptions, + ) { + const queryShape: MessageQueryShape = { + ...messagePaginationOptions, + limit: requestedPageSize, + }; + const isJump = this.isJumpQueryShape(queryShape); + this.postQueryReconcile({ + // A jump/around page spans both directions; a plain latest page paginates tailward (older). + direction: isJump ? undefined : 'tailward', + isFirstPage: true, + queryShape, + requestedPageSize, + results: { + items: messages, + headward: isJump ? messages[messages.length - 1]?.id : undefined, + tailward: messages[0]?.id, + }, + }); + } + + isJumpQueryShape(queryShape: MessageQueryShape): boolean { + return ( + !!queryShape?.id_around || + !!(queryShape as MessagePaginationOptions)?.created_at_around + ); + } + + jumpToMessage = async ( + messageId: string, + { + focusReason, + focusSignalTtlMs, + pageSize, + suppressFocusSignal, + }: JumpToMessageOptions = {}, + ): Promise => { + let localMessage = this.getItem(messageId); + let interval: AnyInterval | undefined; + let state: Partial> | undefined; + if (localMessage) { + interval = this.locateIntervalForItem(localMessage); + if ( + interval && + !isLogicalInterval(interval) && + !interval.itemIds.includes(messageId) + ) { + // locateIntervalForItem can match by created_at RANGE and return an interval whose range + // spans the target while its loaded itemIds do NOT contain it (e.g. a neighbouring interval + // grew across the target's position without merging). Prefer the interval that actually + // holds the id so the jump activates the window the message really lives in - otherwise, if + // the range-matched interval happens to be active, the jump becomes a no-op. + interval = this.itemIntervals.find( + (candidate) => + !isLogicalInterval(candidate) && candidate.itemIds.includes(messageId), + ); + } + } + + if (localMessage && interval && !isLogicalInterval(interval)) { + state = { + hasMoreHead: interval.hasMoreHead, + hasMoreTail: interval.hasMoreTail, + cursor: this.getCursorFromInterval(interval), + items: this.intervalToItems(interval), + }; + } else if (!localMessage || !interval || isLogicalInterval(interval)) { + const result = await this.executeQuery({ + queryShape: { id_around: messageId, limit: pageSize }, + updateState: false, + }); + localMessage = this.getItem(messageId); + if (!localMessage || !result || !result.targetInterval) { + this.channel.getClient().notifications.addError({ + message: 'Jump to message unsuccessful', + origin: { emitter: 'MessagePaginator.jumpToMessage', context: { messageId } }, + options: { type: 'api:messages:query:failed' }, + }); + return false; + } + interval = result.targetInterval; + state = isLogicalInterval(interval) + ? result.stateCandidate + : { + ...result.stateCandidate, + hasMoreHead: interval.hasMoreHead, + hasMoreTail: interval.hasMoreTail, + // Prefer the cursor derived during postQueryReconcile, but fall back to + // interval-derived cursor to keep jumps consistent if the stateCandidate + // is partial. + cursor: result.stateCandidate.cursor ?? this.getCursorFromInterval(interval), + items: this.intervalToItems(interval), + }; + } + + if (!this.isActiveInterval(interval)) { + this.setActiveInterval(interval, { updateState: false }); + } + if (state) this.state.partialNext(state); + if (!suppressFocusSignal) { + this.emitMessageFocusSignal({ + messageId, + reason: focusReason ?? 'jump-to-message', + ttlMs: focusSignalTtlMs, + }); + } + return true; + }; + + jumpToTheLatestMessage = async (options?: JumpToMessageOptions): Promise => { + let latestMessageId: string | undefined; + if (!(this.itemIntervals[0] as Interval)?.isHead) { + // load the newest page in case pagination is currently on an older window (an empty/partial + // headward response marks the interval as the head) + await this.executeQuery({ direction: 'headward', updateState: false }); + } + + // Re-read itemIntervals AFTER the query: the getter returns a fresh array each call, so a + // reference captured before executeQuery would be stale and miss the head we just loaded. + const headInterval = this.itemIntervals[0] as Interval | undefined; + if (headInterval?.isHead) { + latestMessageId = headInterval.itemIds.slice(-1)[0]; + } + + if (!latestMessageId) { + this.channel.getClient().notifications.addError({ + message: 'Jump to latest message unsuccessful', + origin: { emitter: 'MessagePaginator.jumpToTheLatestMessage' }, + options: { type: 'api:message:query:failed' }, + }); + return false; + } + + return await this.jumpToMessage(latestMessageId, { + suppressFocusSignal: true, + ...options, + focusReason: 'jump-to-latest', + }); + }; + + /** + * Fold an already fetched newest (head) page into the currently loaded items without issuing a + * query. The caller supplies a page it obtained on its own and this reconciles it against what is + * loaded, instead of rerunning the first page query, so the loaded set is updated in place rather + * than blanked and reloaded. Two cases, decided by whether the incoming page overlaps the loaded + * head: + * + * 1. OVERLAP - the incoming page shares at least one id with the loaded head (fewer than a full + * page is new). Merge in place: existing items are reconciled by id (edits, soft deletes), new + * items are appended and every already loaded item (including older pages already paged in) is + * kept. `hasMoreTail`/`cursor.tailward` are left as-is so the page can be any size, so deriving + * "has older items" from its length would wrongly clear it while older items remain. + * + * 2. DISJOINT - the incoming page shares no id with the loaded head (at least a full page is new). + * Merging would weld the two across the gap (the interval merge treats two head intervals as + * overlapping when one reaches further headward), hiding the items in between with no way to + * reach them. Instead the loaded set is discarded and rebuilt from the incoming page as a fresh + * contiguous head (`hasMoreTail: true`, cursor reanchored to the page's oldest item) so the + * gap and older history load again when paginating older. + * + * Both paths emit exactly once and never blank the loaded set. Noop unless the page is non empty + * and the newest slice is both loaded AND the interval currently in view (the head interval is + * anchored at the head and active); when the caller has jumped to a separate older window the + * merge is skipped so their position is preserved, and the incoming page is picked up on a later + * load. + */ + mergeNewestPage = (page: LocalMessage[]) => { + if (!page?.length) return; + const headInterval = this.itemIntervals[0] as Interval | undefined; + if (!headInterval?.isHead) return; + // Only reconcile when the head is the interval currently in view. If the caller jumped to a + // separate (older) window, that window is active and the head is merely still-loaded underneath; + // reconciling would switch the view to the head and yank them to the newest. Skip to preserve + // their position (the newest page is picked up on scroll / a later load). + if (!this.isActiveInterval(headInterval)) return; + + const loadedIds = new Set(headInterval.itemIds); + const overlapsLoadedHead = page.some((item) => loadedIds.has(this.getItemId(item))); + + if (!overlapsLoadedHead) { + // Disjoint window: rebuild from the fetched page as a fresh newest slice. Clearing + // the stale intervals first ensures `ingestPage` builds a single head interval instead of + // merging across the gap so reanchoring the cursor to this page's oldest item keeps the next + // "load older" contiguous. + this.setIntervals([]); + this.setActiveInterval(undefined); + const resetInterval = this.ingestPage({ + page, + isHead: true, + // Disjoint means the previously loaded head was entirely OLDER than this newest window, so + // there is always older data to load (the gap + the prior history) - keep hasMoreTail true. + isTail: false, + setActive: false, + }); + if (!resetInterval) return; + this.setActiveInterval(resetInterval, { updateState: false }); + this.state.partialNext({ + items: this.intervalToItems(resetInterval), + cursor: this.getCursorFromInterval(resetInterval), + hasMoreHead: resetInterval.hasMoreHead, + hasMoreTail: resetInterval.hasMoreTail, + }); + return; + } + + // Overlapping window: merge in place, preserving the older boundary. + const interval = this.ingestPage({ page, isHead: true, setActive: false }); + if (!interval) return; + + this.setActiveInterval(interval, { updateState: false }); + this.state.partialNext({ + items: this.intervalToItems(interval), + // The newest slice is loaded (head anchored), so after merging the head window there is + // nothing newer to load. hasMoreTail / cursor are deliberately preserved (see above). + hasMoreHead: false, + }); + }; + + protected resolveUnreadBoundaryIdsByTimestamp = ({ + lastReadAt, + messages, + }: { + lastReadAt: Date; + messages: LocalMessage[]; + }): { firstUnreadMessageId: string | null; lastReadMessageId: string | null } => { + // Messages are expected in chronological order. We find: + // - lastReadMessageId: newest message with created_at <= lastReadAt + // - firstUnreadMessageId: first message with created_at > lastReadAt + // + // If the page starts after lastReadAt, the entire page is unread and the first message is + // used as unread anchor (legacy "whole channel is unread" behavior for this queried window). + const lastReadTimestamp = lastReadAt.getTime(); + if (!Number.isFinite(lastReadTimestamp) || !messages.length) { + return { firstUnreadMessageId: null, lastReadMessageId: null }; + } + + let firstUnreadMessageId: string | null = null; + let lastReadMessageId: string | null = null; + + for (const message of messages) { + const messageTimestamp = getMessageCreatedAtTimestamp(message); + if (messageTimestamp === null) continue; + + if (messageTimestamp <= lastReadTimestamp) { + lastReadMessageId = message.id; + } else if (!firstUnreadMessageId) { + firstUnreadMessageId = message.id; + } + } + + const firstMessageWithTimestamp = messages.find( + (message) => getMessageCreatedAtTimestamp(message) !== null, + ); + const firstMessageTimestamp = + firstMessageWithTimestamp && + getMessageCreatedAtTimestamp(firstMessageWithTimestamp); + if ( + firstMessageWithTimestamp && + typeof firstMessageTimestamp === 'number' && + lastReadTimestamp < firstMessageTimestamp + ) { + return { + firstUnreadMessageId: firstMessageWithTimestamp.id, + lastReadMessageId, + }; + } + + return { firstUnreadMessageId, lastReadMessageId }; + }; + + emitMessageFocusSignal = ({ + messageId, + reason, + ttlMs = 3000, + }: { + messageId: string; + reason: MessageFocusReason; + ttlMs?: number; + }): MessageFocusSignal => { + this.messageFocusSignalToken += 1; + const signal: MessageFocusSignal = { + messageId, + reason, + token: this.messageFocusSignalToken, + createdAt: Date.now(), + ttlMs, + }; + + if (this.clearMessageFocusSignalTimeoutId) { + clearTimeout(this.clearMessageFocusSignalTimeoutId); + this.clearMessageFocusSignalTimeoutId = null; + } + + this.messageFocusSignal.next({ signal }); + + // NOTE: the auto-dismissal countdown is intentionally NOT started here. A focused message may + // be emitted while its message list is not yet visible (e.g. the channel is covered by a thread + // panel when a "view in channel" jump resolves), so measuring the highlight lifetime from the + // moment the jump resolved would burn it while the message is still off-screen. The consumer + // starts the countdown via `scheduleMessageFocusSignalClear` once the message is actually + // viewed. + return signal; + }; + + /** + * Starts the auto-dismissal countdown for the currently active focus signal. Call this once the + * focused message has been viewed (rendered and visible), so the highlight's lifetime is measured + * from when the user could actually see it rather than from when the jump resolved. No-op if the + * signal has already been cleared or superseded (guarded by `token`). + */ + scheduleMessageFocusSignalClear = ({ + token, + ttlMs, + }: { token?: number; ttlMs?: number } = {}) => { + const current = this.messageFocusSignal.getLatestValue().signal; + if (!current) return; + if (typeof token !== 'undefined' && current.token !== token) return; + + if (this.clearMessageFocusSignalTimeoutId) { + clearTimeout(this.clearMessageFocusSignalTimeoutId); + this.clearMessageFocusSignalTimeoutId = null; + } + + this.clearMessageFocusSignalTimeoutId = setTimeout(() => { + this.clearMessageFocusSignal({ token: current.token }); + }, ttlMs ?? current.ttlMs); + }; + + clearMessageFocusSignal = ({ token }: { token?: number } = {}) => { + const current = this.messageFocusSignal.getLatestValue().signal; + if (!current) return; + if (typeof token !== 'undefined' && current.token !== token) return; + + if (this.clearMessageFocusSignalTimeoutId) { + clearTimeout(this.clearMessageFocusSignalTimeoutId); + this.clearMessageFocusSignalTimeoutId = null; + } + + this.messageFocusSignal.next({ signal: null }); + }; + + clearStateAndCache() { + this.resetState(); + this._itemIndex.clear(); + this.clearMessageFocusSignal(); + } + + /** + * Partial truncation for `channel.truncated` carrying a `truncated_at`: drop every loaded + * message strictly older than the cutoff (keeping newer ones) across all loaded windows, + * mirroring the legacy per-message-set pruning. For a full truncation (no `truncated_at`) use + * {@link MessageIntervalPaginator.clearStateAndCache} instead. + * + * Batched and edge-classified: because messages are chronological, each interval is classified by + * its `tail` (oldest) and `head` (newest) edges, so only the single interval that *straddles* the + * cutoff is scanned member-by-member — the rest are kept or dropped wholesale. The straddling + * interval becomes the new global tail (nothing older than the cutoff exists anymore), so its + * `isTail`/`hasMoreTail` are set; intervals entirely newer keep their flags (unloaded older + * messages may still sit between them and the cutoff). The active window is re-emitted once. + */ + truncate = ({ truncatedAt }: { truncatedAt: Date }) => { + const cutoff = truncatedAt.getTime(); + if (Number.isNaN(cutoff)) return; + + const isOld = (item: LocalMessage | undefined) => { + const time = item?.created_at ? new Date(item.created_at).getTime() : undefined; + return typeof time === 'number' && time < cutoff; + }; + + const removedIds: string[] = []; + const survivingIntervals: AnyInterval[] = []; + // iterate from head to tail + for (const interval of this.itemIntervals) { + const edges = this.getIntervalPaginationEdges(interval); + if (!edges || !isOld(edges.tail)) { + survivingIntervals.push(interval); // oldest edge >= cutoff → nothing to drop + } else if (isOld(edges.head)) { + removedIds.push(...interval.itemIds); // newest edge < cutoff → whole interval is older + } else { + // determines the cutoff. Items are chronological, so the old ones are at the beginning of the array, + // binary-search rather than scanning every member. + const ids = interval.itemIds; + const splitIndex = lowerBound( + ids.length, + (index) => !isOld(this._itemIndex.get(ids[index])), + ); + removedIds.push(...ids.slice(0, splitIndex)); + const kept = ids.slice(splitIndex); + survivingIntervals.push( + isLogicalInterval(interval) + ? { ...interval, itemIds: kept } + : { ...interval, itemIds: kept, isTail: true, hasMoreTail: false }, + ); + } + } + + if (!removedIds.length) return; + for (const id of removedIds) this._itemIndex.remove(id); + + // No re-sort needed: `survivingIntervals` preserves the order of the already-sorted + // `itemIntervals` (we only keep/prune/drop, never reorder), and truncation removes only + // tailward items — an interval's head edge (the intervalComparator sort key) never changes. + this.setIntervals(survivingIntervals); + + // Single re-emit of the active window (setIntervals does not emit). + const active = this._activeIntervalId + ? this._itemIntervals.get(this._activeIntervalId) + : undefined; + if (active && !isLogicalInterval(active)) { + this.setActiveInterval(active); + return; + } + + // The active window was truncated away entirely. It sat below the cutoff, so it was older + // than every survivor — activate the nearest surviving window (the tail-most, i.e. oldest, + // anchored interval) rather than emitting an empty page, which would blank the message list. + const anchoredSurvivors = this.itemIntervals.filter( + (itv): itv is Interval => !isLogicalInterval(itv), + ); + + // If the active interval was truncated, we move to the neareast interval - which is the tail now + const fallback = this.getTailIntervalFromSortedIntervals(anchoredSurvivors); + if (fallback) { + this.setActiveInterval(fallback); + } else { + // Nothing loaded survived the truncation. + this.state.partialNext({ items: [] }); + } + }; + + applyMessageDeletionForUser = ({ + userId, + hardDelete = false, + deletedAt, + }: { + userId: string; + hardDelete?: boolean; + deletedAt: Date; + }) => { + const loadedMessages = this.items ?? []; + + for (const message of loadedMessages) { + if (message.user?.id === userId) { + if (hardDelete) { + this.removeItem({ id: message.id }); + } else { + this.ingestItem( + toDeletedMessage({ + message, + hardDelete, + deletedAt, + }) as LocalMessage, + ); + } + continue; + } + + if ( + message.quoted_message?.user?.id === userId && + message.quoted_message.type !== 'deleted' + ) { + this.ingestItem({ + ...message, + quoted_message: toDeletedMessage({ + message: message.quoted_message, + hardDelete, + deletedAt, + }) as LocalMessage, + }); + } + } + }; + + /** + * Ensures quoted-message snapshots across loaded paginator cache are in sync + * with the provided message. + * + * Scans cached messages and updates any item where `quoted_message_id` + * matches `message.id`. + */ + reflectQuotedMessageUpdate = (message: LocalMessage) => { + const cachedMessages = this._itemIndex.values(); + + for (const cachedMessage of cachedMessages) { + if (cachedMessage.quoted_message_id !== message.id) continue; + + this.ingestItem({ + ...cachedMessage, + quoted_message: message, + }); + } + }; + + /** + * Reflect an updated `user` object onto every cached message authored by that user, mirroring + * the legacy `ChannelState.updateUserMessages` for the main message list (does not touch + * `quoted_message.user` — that is not part of the legacy behavior). + * + * Batched: a user rename can affect many messages, so this patches the shared item index and + * re-emits the active window a single time (if it held an affected message) rather than one + * `ingestItem` re-emit per message. + */ + reflectUserUpdate = (user: UserResponse) => { + const activeIds = new Set((this.items ?? []).map((m) => this.getItemId(m))); + let activeAffected = false; + for (const message of this._itemIndex.values()) { + if (message.user?.id !== user.id) continue; + this._itemIndex.setOne({ ...message, user }); + if (activeIds.has(this.getItemId(message))) activeAffected = true; + } + if (activeAffected) { + this.state.partialNext({ + items: (this.items ?? []).map((m) => this.getItem(this.getItemId(m)) ?? m), + }); + } + }; + + /** + * Apply a reaction WS event (`reaction.new` / `reaction.updated` / `reaction.deleted`) to the + * cached message. The event's `message` already carries the server-updated + * `reaction_groups` / `latest_reactions`; only `own_reactions` needs local preservation so a + * cross-user reaction does not wipe the current user's reactions. This re-homes what + * `ChannelState.addReaction` / `removeReaction` used to do off the now-removed + * `channel.state.messages` / `channel.state.threads` caches (the same logic backs the thread + * paginator via `Thread.messagePaginator`). + * + * `own_reactions` is seeded from the currently cached item (so another user's reaction keeps ours), + * falling back to the event's own_reactions when the message is not loaded — matching the legacy + * behavior where `_updateMessage` only mutated a message that existed locally. + * + * @param params + * @param {MessageResponse | LocalMessage} params.message The reaction event's message, carrying the + * server-computed `reaction_groups` / `latest_reactions`. Ingested as-is except for `own_reactions`. + * @param {ReactionResponse} params.reaction The reaction from the event. Only added to/removed from + * `own_reactions` when its `user_id` is the current user; otherwise the current user's + * `own_reactions` are left untouched. + * @param {boolean} [params.removed=false] `true` for `reaction.deleted` (remove the reaction from + * `own_reactions`); `false` for `reaction.new` / `reaction.updated` (add it). + * @param {boolean} [params.enforceUnique=false] When adding, first clear the current user's existing + * `own_reactions` so only the incoming one remains (used by `reaction.updated`, where a user's + * reaction replaces their previous one). + */ + reflectReaction = ({ + enforceUnique = false, + message, + reaction, + removed = false, + }: { + message: MessageResponse | LocalMessage; + reaction: ReactionResponse; + enforceUnique?: boolean; + removed?: boolean; + }) => { + const formatted = formatMessage(message); + const existing = this.getItem(formatted.id); + const baseOwnReactions = existing?.own_reactions ?? formatted.own_reactions ?? []; + const own_reactions = removed + ? this.removeOwnReactionOfType(baseOwnReactions, reaction) + : this.addOwnReaction(baseOwnReactions, reaction, enforceUnique); + this.ingestItem({ ...formatted, own_reactions }); + }; + + private removeOwnReactionOfType( + ownReactions: ReactionResponse[], + reaction: ReactionResponse, + ): ReactionResponse[] { + return ownReactions.filter( + (r) => r.user_id !== reaction.user_id || r.type !== reaction.type, + ); + } + + private addOwnReaction( + ownReactions: ReactionResponse[], + reaction: ReactionResponse, + enforceUnique: boolean, + ): ReactionResponse[] { + const base = enforceUnique + ? [] + : this.removeOwnReactionOfType(ownReactions, reaction); + if (this.channel.getClient().userID === reaction.user_id) { + return [...base, reaction]; + } + return base; + } + + /** + * Map a timestamp to a loaded message — the first message in the latest (head) window whose + * `created_at` is >= `timestampMs` (mirrors the legacy `ChannelState.findMessageByTimestamp` + * lower-bound search), or the newest loaded message when the timestamp is beyond it. Used by the + * receipts tracker to resolve read/delivered cursors. Searches the newest loaded window — where + * read cursors live — which is already sorted, so this is O(log n) with no re-sort. + */ + findItemByTimestamp = ( + timestampMs: number, + exactTsMatch = false, + ): LocalMessage | null => { + const items = this.latestItems; // ascending by created_at + if (!items.length) return null; + // Resolve the last message created AT OR BEFORE `timestampMs` (floor). The sole caller is + // read/delivered cursor resolution (MessageReceiptsTracker): the cursor carries the timestamp of + // the last message a participant reached, so a message created strictly after the cursor has NOT + // been reached. A ceil match (first message >= target) would over-count it — e.g. a participant + // whose read cursor predates every loaded message would be reported as having read the oldest one. + // `lowerBound` returns the first index whose created_at is strictly greater than the target, so + // the floor is the item immediately before it. + const firstAfter = lowerBound(items.length, (i) => { + const t = getMessageCreatedAtTimestamp(items[i]); + return t === null || t > timestampMs; + }); + if (firstAfter === 0) return null; // target precedes every loaded message + const found = items[firstAfter - 1]; + const foundTimestamp = getMessageCreatedAtTimestamp(found); + // A message without a resolvable created_at (e.g. an optimistic message still missing its + // server timestamp) cannot be located by timestamp. + if (foundTimestamp === null) return null; + if (!exactTsMatch) return found; + return foundTimestamp === timestampMs ? found : null; + }; + + filterQueryResults = (items: LocalMessage[]) => + items.filter(this.shouldIncludeMessageInInterval.bind(this)); + + private getCanonicalQueryItems(items: LocalMessage[]): LocalMessage[] { + return [...items].sort(this.itemOrderComparator); + } +} + +const makeDeriveCursor = + ( + paginator: MessageIntervalPaginator, + ): CursorDerivator => + (ctx) => { + // Not included in the interval (filtered out by MessageIntervalPaginator.filterQueryResults). + // + // IMPORTANT: We must keep cursor derivation consistent with the ingested interval. + // The interval is built from the filtered page, but ctx.page contains the raw response. + // Around/linear derivators compare page edges and lengths against interval.itemIds. If we + // pass a page that includes locally filtered messages (e.g. shadowed), those comparisons + // can incorrectly conclude that the page is not at the dataset bounds. + const pageWithPermittedMessages: LocalMessage[] = []; + let filteredLocallyCount = 0; + for (const message of ctx.page) { + if (!paginator.shouldIncludeMessageInInterval(message)) { + filteredLocallyCount++; + } else { + pageWithPermittedMessages.push(message); + } + } + + const requestedPageSizeAfterAdjustment = Math.max( + 0, + ctx.requestedPageSize - filteredLocallyCount, + ); + + if ( + ctx.interval && + ctx.interval.itemIds.length + filteredLocallyCount < ctx.page.length + ) { + console.error( + 'error', + 'Corrupted message set state: parent set size < returned page size', + ); + return { + cursor: ctx.cursor, + hasMoreHead: ctx.hasMoreHead, + hasMoreTail: ctx.hasMoreTail, + }; + } + + const injectCursor = ({ + hasMoreHead, + hasMoreTail, + }: { + hasMoreHead: boolean; + hasMoreTail: boolean; + }): CursorDeriveResult => { + const cursor: PaginatorCursor = { + headward: !hasMoreHead ? null : (ctx.interval?.itemIds.slice(-1)[0] ?? null), + tailward: !hasMoreTail ? null : (ctx.interval?.itemIds[0] ?? null), + }; + return { cursor, hasMoreHead, hasMoreTail }; + }; + + if ((ctx.queryShape as MessagePaginationOptions)?.created_at_around) { + return injectCursor( + deriveCreatedAtAroundPaginationFlags< + LocalMessage, + MessagePaginationOptions, + MessageIntervalPaginator + >({ + ...ctx, + paginator, + page: pageWithPermittedMessages, + requestedPageSize: requestedPageSizeAfterAdjustment, + }), + ); + } else if (ctx.queryShape?.id_around) { + return injectCursor( + deriveIdAroundPaginationFlags({ + ...ctx, + page: pageWithPermittedMessages, + requestedPageSize: requestedPageSizeAfterAdjustment, + }), + ); + } else { + return injectCursor( + deriveLinearPaginationFlags({ + ...ctx, + page: pageWithPermittedMessages, + requestedPageSize: requestedPageSizeAfterAdjustment, + }), + ); + } + }; diff --git a/src/pagination/paginators/MessagePaginator.ts b/src/pagination/paginators/MessagePaginator.ts index 7688c94d6..68655e24c 100644 --- a/src/pagination/paginators/MessagePaginator.ts +++ b/src/pagination/paginators/MessagePaginator.ts @@ -1,136 +1,26 @@ -import type { - AnyInterval, - CursorDerivator, - CursorDeriveResult, - ExecuteQueryReturnValue, - Interval, - PaginationDirection, - PaginationQueryParams, - PaginatorCursor, - PaginatorState, - PostQueryReconcileParams, -} from './BasePaginator'; +import type { ExecuteQueryReturnValue, PostQueryReconcileParams } from './BasePaginator'; import { - BasePaginator, - isLogicalInterval, - type PaginationQueryReturnValue, - type PaginationQueryShapeChangeIdentifier, - type PaginatorOptions, - ZERO_PAGE_CURSOR, -} from './BasePaginator'; -import type { - AscDesc, - LocalMessage, - MessagePaginationOptions, - MessageResponse, - PinnedMessagePaginationOptions, - ReactionResponse, - UserResponse, -} from '../../types'; -import type { Channel } from '../../channel'; + type MessagePaginatorOptions as BaseMessagePaginatorOptions, + type JumpToMessageOptions, + MessageIntervalPaginator, + type MessageQueryShape, +} from './MessageIntervalPaginator'; +import type { LocalMessage } from '../../types'; import { StateStore } from '../../store'; -import { formatMessage, generateUUIDv4, toDeletedMessage } from '../../utils'; -import { makeComparator } from '../sortCompiler'; -import type { FieldToDataResolver } from '../types.normalization'; -import { resolveDotPathValue } from '../utility.normalization'; -import { lowerBound } from '../utility.search'; -import { ItemIndex } from '../ItemIndex'; -import { deriveCreatedAtAroundPaginationFlags } from '../cursorDerivation'; -import { deriveIdAroundPaginationFlags } from '../cursorDerivation/idAroundPaginationFlags'; -import { deriveLinearPaginationFlags } from '../cursorDerivation/linearPaginationFlags'; -export type MessageFocusReason = - | 'jump-to-message' - | 'jump-to-first-unread' - | 'jump-to-latest'; - -export type MessageFocusSignal = { - messageId: string; - reason: MessageFocusReason; - token: number; - createdAt: number; - ttlMs: number; -}; - -export type MessageFocusSignalState = { - signal: MessageFocusSignal | null; -}; - -export type JumpToMessageOptions = { - pageSize?: number; - /** - * Optional reason attached to emitted focus signal. - * Defaults to `jump-to-message`. - */ - focusReason?: MessageFocusReason; - /** - * TTL for the emitted focus signal in milliseconds. - * Defaults to `3000`. - */ - focusSignalTtlMs?: number; - /** - * If true, suppresses focus signal emission after a successful jump. - */ - suppressFocusSignal?: boolean; -}; - -export type MessagePaginatorSort = { created_at: AscDesc } | { created_at: AscDesc }[]; - -export type MessagePaginatorFilter = { - cid: string; - parent_id?: string; -}; - -const DEFAULT_BACKEND_SORT: MessagePaginatorSort = { - created_at: 1, -}; - -// server's default size is 100 -const DEFAULT_CHANNEL_MESSAGE_LIST_PAGE_SIZE = 100; - -export type MessagePaginatorState = PaginatorState; -export type MessageQueryShape = MessagePaginationOptions | PinnedMessagePaginationOptions; - -/** - * At the moment all the pagination parameters are just different types of cursors, e.g. - * id_lt, id_gt, ... - * But we always paginate within the same list without changing the sorting params. - * It is currently not possible to change the sorting params. - */ -const hasPaginationQueryShapeChanged: PaginationQueryShapeChangeIdentifier< - MessageQueryShape -> = () => false; - -const dataFieldFilterResolver: FieldToDataResolver = { - matchesField: () => true, - resolve: (message, path) => resolveDotPathValue(message, path), -}; - -const getMessageCreatedAtTimestamp = (message: LocalMessage): number | null => { - if (!(message.created_at instanceof Date)) return null; - const timestamp = message.created_at.getTime(); - return Number.isFinite(timestamp) ? timestamp : null; -}; - -export type MessagePaginatorOptions = { - channel: Channel; - id?: string; - itemIndex?: ItemIndex; - parentMessageId?: string; - /** - * Sort passed to backend message/replies query. - * Does not affect in-memory item ordering. - */ - requestSort?: MessagePaginatorSort; - /** - * @deprecated Use `requestSort` instead. - */ - sort?: MessagePaginatorSort; - /** - * In-memory ordering for items exposed by paginator state. - */ - itemOrder?: MessagePaginatorSort; - paginatorOptions?: PaginatorOptions; +export type { + JumpToMessageOptions, + MessageFocusReason, + MessageFocusSignal, + MessageFocusSignalState, + MessagePaginatorFilter, + MessagePaginatorSort, + MessagePaginatorState, + MessageQueryShape, +} from './MessageIntervalPaginator'; +export { MessageIntervalPaginator } from './MessageIntervalPaginator'; + +export type MessagePaginatorOptions = BaseMessagePaginatorOptions & { /** * Controls whether `jumpToTheFirstUnreadMessage()` should prefer the `unreadStateSnapshot` * state over `channel.state.read[...]`. @@ -170,13 +60,11 @@ export type LiveViewState = { }; /** - * MessagePaginator allows configuring backend request sort, while keeping internal item ordering stable. - * Filtering of ingested items is still limited to local predicates (`filterQueryResults`). + * MessagePaginator extends {@link MessageIntervalPaginator} with the unread/live-view concern: + * an independent unread reference snapshot, the UI-driven "viewing the latest messages" signal, and + * the "jump to first unread" navigation built on top of them. */ -export class MessagePaginator extends BasePaginator { - private readonly _id: string; - private channel: Channel; - private parentMessageId?: string; +export class MessagePaginator extends MessageIntervalPaginator { private unreadReferencePolicy: 'snapshot' | 'read-state-only'; /** * Independent unread reference state (not tied to `channel.state.read`). @@ -189,59 +77,12 @@ export class MessagePaginator extends BasePaginator; - readonly messageFocusSignal: StateStore; - private clearMessageFocusSignalTimeoutId: ReturnType | null = null; - private messageFocusSignalToken = 0; - protected _requestSort = DEFAULT_BACKEND_SORT; - protected _itemOrder: MessagePaginatorSort = DEFAULT_BACKEND_SORT; - protected _nextQueryShape: MessageQueryShape | undefined; - sortComparator: (a: LocalMessage, b: LocalMessage) => number; - /** - * Single source of truth for whether a message should be included in paginator intervals/state. - * Keep this consistent with `filterQueryResults` AND cursor flag derivation. - */ - shouldIncludeMessageInInterval(message: LocalMessage): boolean { - return !message.shadowed; - } - - protected get intervalItemIdsAreHeadFirst(): boolean { - // Messages are stored in chronological order (created_at asc) within an interval. - // Pagination "head" (newest side) is therefore at the END of the `itemIds` array. - return false; - } - - protected get intervalSortDirection(): 'asc' | 'desc' { - // Head edge is newest, but sortComparator is created_at asc => newer head edges - // should come first => reverse interval ordering. - return 'desc'; - } constructor({ - channel, - id, - itemIndex = new ItemIndex({ getId: (item) => item.id }), - parentMessageId, - requestSort, - sort, - itemOrder, - paginatorOptions, unreadReferencePolicy = 'snapshot', + ...options }: MessagePaginatorOptions) { - const resolvedRequestSort = requestSort ?? sort ?? DEFAULT_BACKEND_SORT; - const resolvedItemOrder = itemOrder ?? resolvedRequestSort; - super({ - hasPaginationQueryShapeChanged, - initialCursor: ZERO_PAGE_CURSOR, - itemIndex, - ...paginatorOptions, - pageSize: paginatorOptions?.pageSize ?? DEFAULT_CHANNEL_MESSAGE_LIST_PAGE_SIZE, - }); - this.config.deriveCursor = makeDeriveCursor(this); - this.channel = channel; - this.parentMessageId = parentMessageId; - this._id = id ?? `message-paginator-${generateUUIDv4()}`; - this._requestSort = resolvedRequestSort; - this._itemOrder = resolvedItemOrder; + super(options); this.unreadReferencePolicy = unreadReferencePolicy; this.unreadStateSnapshot = new StateStore({ lastReadAt: null, @@ -252,144 +93,8 @@ export class MessagePaginator extends BasePaginator({ isViewingLive: false, }); - this.messageFocusSignal = new StateStore({ - signal: null, - }); - this.sortComparator = makeComparator({ - sort: this._requestSort, - resolvePathValue: resolveDotPathValue, - tiebreaker: (l, r) => { - const leftId = this.getItemId(l); - const rightId = this.getItemId(r); - return leftId < rightId ? -1 : leftId > rightId ? 1 : 0; - }, - }); - this.config.itemOrderComparator = makeComparator({ - sort: this._itemOrder, - resolvePathValue: resolveDotPathValue, - tiebreaker: (l, r) => { - const leftId = this.getItemId(l); - const rightId = this.getItemId(r); - return leftId < rightId ? -1 : leftId > rightId ? 1 : 0; - }, - }); - this.setFilterResolvers([dataFieldFilterResolver]); - } - - get id() { - return this._id; - } - - get sort() { - return this._requestSort ?? DEFAULT_BACKEND_SORT; - } - - get requestSort() { - return this._requestSort ?? DEFAULT_BACKEND_SORT; - } - - get itemOrder() { - return this._itemOrder ?? this._requestSort ?? DEFAULT_BACKEND_SORT; - } - - /** - * Even though we do not send filters object to the server, we need to have filters for client-side item ingestion logic. - */ - buildFilters = (): MessagePaginatorFilter => ({ - cid: this.channel.cid, - ...(this.parentMessageId ? { parent_id: this.parentMessageId } : {}), - }); - - // invoked inside BasePaginator.executeQuery() to keep it as a query descriptor; - protected getNextQueryShape({ - direction, - }: Omit< - PaginationQueryParams, - 'isFirstPageQuery' - >): MessageQueryShape { - return { - limit: this.pageSize, - [direction === 'tailward' ? 'id_lt' : 'id_gt']: - direction && this.cursor?.[direction], - }; } - getCursorFromQueryResults = ({ - direction, - items, - }: { - direction?: PaginationDirection; - items: LocalMessage[]; - }) => { - if (!items.length) { - return { - tailward: undefined, - headward: undefined, - }; - } - - const start = items[0]; - const end = items[items.length - 1]; - - // Newer side is the pagination head for messages. Which bound is considered "head" - // is determined by intervalItemIdsAreHeadFirst (see BasePaginator.getIntervalPaginationEdges). - const head = this.intervalItemIdsAreHeadFirst ? start : end; - const tail = this.intervalItemIdsAreHeadFirst ? end : start; - - // if there is no direction, then we are jumping, and we want to set both directions in the cursor - return { - tailward: !direction || direction === 'tailward' ? this.getItemId(tail) : undefined, - headward: !direction || direction === 'headward' ? this.getItemId(head) : undefined, - }; - }; - - query = async ({ - direction, - }: PaginationQueryParams): Promise< - PaginationQueryReturnValue - > => { - // get the params only if they were not generated previously - if (!this._nextQueryShape) { - this._nextQueryShape = this.getNextQueryShape({ direction }); - } - - const options = this._nextQueryShape; - let items: LocalMessage[]; - let tailward: string | undefined; - let headward: string | undefined; - if (this.config.doRequest) { - const result = await this.config.doRequest(options); - items = this.getCanonicalQueryItems(result?.items ?? []); - // if there is no direction, then we are jumping, and we want to set both directions in the cursor - tailward = - !direction || direction === 'tailward' - ? (result.cursor?.tailward ?? undefined) - : undefined; - headward = - !direction || direction === 'headward' - ? (result.cursor?.headward ?? undefined) - : undefined; - } else { - const { messages } = this.parentMessageId - ? await this.channel.getReplies( - this.parentMessageId, - options, - Array.isArray(this.requestSort) ? this.requestSort : [this.requestSort], - ) - : await this.channel.query({ - messages: options, - // todo: why do we query for watchers? - // watchers: { limit: this.pageSize }, - }); - items = this.getCanonicalQueryItems(messages.map(formatMessage)); - const cursor = this.getCursorFromQueryResults({ direction, items }); - tailward = cursor.tailward; - headward = cursor.headward; - } - - return { items, headward, tailward }; - }; - /** * (Re)seed the unread state snapshot from the current own read state. * @@ -436,244 +141,6 @@ export class MessagePaginator extends BasePaginator => { - let localMessage = this.getItem(messageId); - let interval: AnyInterval | undefined; - let state: Partial> | undefined; - if (localMessage) { - interval = this.locateIntervalForItem(localMessage); - if ( - interval && - !isLogicalInterval(interval) && - !interval.itemIds.includes(messageId) - ) { - // locateIntervalForItem can match by created_at RANGE and return an interval whose range - // spans the target while its loaded itemIds do NOT contain it (e.g. a neighbouring interval - // grew across the target's position without merging). Prefer the interval that actually - // holds the id so the jump activates the window the message really lives in - otherwise, if - // the range-matched interval happens to be active, the jump becomes a no-op. - interval = this.itemIntervals.find( - (candidate) => - !isLogicalInterval(candidate) && candidate.itemIds.includes(messageId), - ); - } - } - - if (localMessage && interval && !isLogicalInterval(interval)) { - state = { - hasMoreHead: interval.hasMoreHead, - hasMoreTail: interval.hasMoreTail, - cursor: this.getCursorFromInterval(interval), - items: this.intervalToItems(interval), - }; - } else if (!localMessage || !interval || isLogicalInterval(interval)) { - const result = await this.executeQuery({ - queryShape: { id_around: messageId, limit: pageSize }, - updateState: false, - }); - localMessage = this.getItem(messageId); - if (!localMessage || !result || !result.targetInterval) { - this.channel.getClient().notifications.addError({ - message: 'Jump to message unsuccessful', - origin: { emitter: 'MessagePaginator.jumpToMessage', context: { messageId } }, - options: { type: 'api:messages:query:failed' }, - }); - return false; - } - interval = result.targetInterval; - state = isLogicalInterval(interval) - ? result.stateCandidate - : { - ...result.stateCandidate, - hasMoreHead: interval.hasMoreHead, - hasMoreTail: interval.hasMoreTail, - // Prefer the cursor derived during postQueryReconcile, but fall back to - // interval-derived cursor to keep jumps consistent if the stateCandidate - // is partial. - cursor: result.stateCandidate.cursor ?? this.getCursorFromInterval(interval), - items: this.intervalToItems(interval), - }; - } - - if (!this.isActiveInterval(interval)) { - this.setActiveInterval(interval, { updateState: false }); - } - if (state) this.state.partialNext(state); - if (!suppressFocusSignal) { - this.emitMessageFocusSignal({ - messageId, - reason: focusReason ?? 'jump-to-message', - ttlMs: focusSignalTtlMs, - }); - } - return true; - }; - - jumpToTheLatestMessage = async (options?: JumpToMessageOptions): Promise => { - let latestMessageId: string | undefined; - if (!(this.itemIntervals[0] as Interval)?.isHead) { - // load the newest page in case pagination is currently on an older window (an empty/partial - // headward response marks the interval as the head) - await this.executeQuery({ direction: 'headward', updateState: false }); - } - - // Re-read itemIntervals AFTER the query: the getter returns a fresh array each call, so a - // reference captured before executeQuery would be stale and miss the head we just loaded. - const headInterval = this.itemIntervals[0] as Interval | undefined; - if (headInterval?.isHead) { - latestMessageId = headInterval.itemIds.slice(-1)[0]; - } - - if (!latestMessageId) { - this.channel.getClient().notifications.addError({ - message: 'Jump to latest message unsuccessful', - origin: { emitter: 'MessagePaginator.jumpToTheLatestMessage' }, - options: { type: 'api:message:query:failed' }, - }); - return false; - } - - return await this.jumpToMessage(latestMessageId, { - suppressFocusSignal: true, - ...options, - focusReason: 'jump-to-latest', - }); - }; - - /** - * Fold an already fetched newest (head) page into the currently loaded items without issuing a - * query. The caller supplies a page it obtained on its own and this reconciles it against what is - * loaded, instead of rerunning the first page query, so the loaded set is updated in place rather - * than blanked and reloaded. Two cases, decided by whether the incoming page overlaps the loaded - * head: - * - * 1. OVERLAP - the incoming page shares at least one id with the loaded head (fewer than a full - * page is new). Merge in place: existing items are reconciled by id (edits, soft deletes), new - * items are appended and every already loaded item (including older pages already paged in) is - * kept. `hasMoreTail`/`cursor.tailward` are left as-is so the page can be any size, so deriving - * "has older items" from its length would wrongly clear it while older items remain. - * - * 2. DISJOINT - the incoming page shares no id with the loaded head (at least a full page is new). - * Merging would weld the two across the gap (the interval merge treats two head intervals as - * overlapping when one reaches further headward), hiding the items in between with no way to - * reach them. Instead the loaded set is discarded and rebuilt from the incoming page as a fresh - * contiguous head (`hasMoreTail: true`, cursor reanchored to the page's oldest item) so the - * gap and older history load again when paginating older. - * - * Both paths emit exactly once and never blank the loaded set. Noop unless the page is non empty - * and the newest slice is both loaded AND the interval currently in view (the head interval is - * anchored at the head and active); when the caller has jumped to a separate older window the - * merge is skipped so their position is preserved, and the incoming page is picked up on a later - * load. - */ - mergeNewestPage = (page: LocalMessage[]) => { - if (!page?.length) return; - const headInterval = this.itemIntervals[0] as Interval | undefined; - if (!headInterval?.isHead) return; - // Only reconcile when the head is the interval currently in view. If the caller jumped to a - // separate (older) window, that window is active and the head is merely still-loaded underneath; - // reconciling would switch the view to the head and yank them to the newest. Skip to preserve - // their position (the newest page is picked up on scroll / a later load). - if (!this.isActiveInterval(headInterval)) return; - - const loadedIds = new Set(headInterval.itemIds); - const overlapsLoadedHead = page.some((item) => loadedIds.has(this.getItemId(item))); - - if (!overlapsLoadedHead) { - // Disjoint window: rebuild from the fetched page as a fresh newest slice. Clearing - // the stale intervals first ensures `ingestPage` builds a single head interval instead of - // merging across the gap so reanchoring the cursor to this page's oldest item keeps the next - // "load older" contiguous. - this.setIntervals([]); - this.setActiveInterval(undefined); - const resetInterval = this.ingestPage({ - page, - isHead: true, - // Disjoint means the previously loaded head was entirely OLDER than this newest window, so - // there is always older data to load (the gap + the prior history) - keep hasMoreTail true. - isTail: false, - setActive: false, - }); - if (!resetInterval) return; - this.setActiveInterval(resetInterval, { updateState: false }); - this.state.partialNext({ - items: this.intervalToItems(resetInterval), - cursor: this.getCursorFromInterval(resetInterval), - hasMoreHead: resetInterval.hasMoreHead, - hasMoreTail: resetInterval.hasMoreTail, - }); - return; - } - - // Overlapping window: merge in place, preserving the older boundary. - const interval = this.ingestPage({ page, isHead: true, setActive: false }); - if (!interval) return; - - this.setActiveInterval(interval, { updateState: false }); - this.state.partialNext({ - items: this.intervalToItems(interval), - // The newest slice is loaded (head anchored), so after merging the head window there is - // nothing newer to load. hasMoreTail / cursor are deliberately preserved (see above). - hasMoreHead: false, - }); - }; - /** * Jumps to the unread reference message. * @@ -787,129 +254,6 @@ export class MessagePaginator extends BasePaginator { - // Messages are expected in chronological order. We find: - // - lastReadMessageId: newest message with created_at <= lastReadAt - // - firstUnreadMessageId: first message with created_at > lastReadAt - // - // If the page starts after lastReadAt, the entire page is unread and the first message is - // used as unread anchor (legacy "whole channel is unread" behavior for this queried window). - const lastReadTimestamp = lastReadAt.getTime(); - if (!Number.isFinite(lastReadTimestamp) || !messages.length) { - return { firstUnreadMessageId: null, lastReadMessageId: null }; - } - - let firstUnreadMessageId: string | null = null; - let lastReadMessageId: string | null = null; - - for (const message of messages) { - const messageTimestamp = getMessageCreatedAtTimestamp(message); - if (messageTimestamp === null) continue; - - if (messageTimestamp <= lastReadTimestamp) { - lastReadMessageId = message.id; - } else if (!firstUnreadMessageId) { - firstUnreadMessageId = message.id; - } - } - - const firstMessageWithTimestamp = messages.find( - (message) => getMessageCreatedAtTimestamp(message) !== null, - ); - const firstMessageTimestamp = - firstMessageWithTimestamp && - getMessageCreatedAtTimestamp(firstMessageWithTimestamp); - if ( - firstMessageWithTimestamp && - typeof firstMessageTimestamp === 'number' && - lastReadTimestamp < firstMessageTimestamp - ) { - return { - firstUnreadMessageId: firstMessageWithTimestamp.id, - lastReadMessageId, - }; - } - - return { firstUnreadMessageId, lastReadMessageId }; - }; - - emitMessageFocusSignal = ({ - messageId, - reason, - ttlMs = 3000, - }: { - messageId: string; - reason: MessageFocusReason; - ttlMs?: number; - }): MessageFocusSignal => { - this.messageFocusSignalToken += 1; - const signal: MessageFocusSignal = { - messageId, - reason, - token: this.messageFocusSignalToken, - createdAt: Date.now(), - ttlMs, - }; - - if (this.clearMessageFocusSignalTimeoutId) { - clearTimeout(this.clearMessageFocusSignalTimeoutId); - this.clearMessageFocusSignalTimeoutId = null; - } - - this.messageFocusSignal.next({ signal }); - - // NOTE: the auto-dismissal countdown is intentionally NOT started here. A focused message may - // be emitted while its message list is not yet visible (e.g. the channel is covered by a thread - // panel when a "view in channel" jump resolves), so measuring the highlight lifetime from the - // moment the jump resolved would burn it while the message is still off-screen. The consumer - // starts the countdown via `scheduleMessageFocusSignalClear` once the message is actually - // viewed. - return signal; - }; - - /** - * Starts the auto-dismissal countdown for the currently active focus signal. Call this once the - * focused message has been viewed (rendered and visible), so the highlight's lifetime is measured - * from when the user could actually see it rather than from when the jump resolved. No-op if the - * signal has already been cleared or superseded (guarded by `token`). - */ - scheduleMessageFocusSignalClear = ({ - token, - ttlMs, - }: { token?: number; ttlMs?: number } = {}) => { - const current = this.messageFocusSignal.getLatestValue().signal; - if (!current) return; - if (typeof token !== 'undefined' && current.token !== token) return; - - if (this.clearMessageFocusSignalTimeoutId) { - clearTimeout(this.clearMessageFocusSignalTimeoutId); - this.clearMessageFocusSignalTimeoutId = null; - } - - this.clearMessageFocusSignalTimeoutId = setTimeout(() => { - this.clearMessageFocusSignal({ token: current.token }); - }, ttlMs ?? current.ttlMs); - }; - - clearMessageFocusSignal = ({ token }: { token?: number } = {}) => { - const current = this.messageFocusSignal.getLatestValue().signal; - if (!current) return; - if (typeof token !== 'undefined' && current.token !== token) return; - - if (this.clearMessageFocusSignalTimeoutId) { - clearTimeout(this.clearMessageFocusSignalTimeoutId); - this.clearMessageFocusSignalTimeoutId = null; - } - - this.messageFocusSignal.next({ signal: null }); - }; - setUnreadSnapshot = (next: Partial): UnreadSnapshotState => { this.unreadStateSnapshot.partialNext(next); return this.unreadStateSnapshot.getLatestValue(); @@ -941,375 +285,8 @@ export class MessagePaginator extends BasePaginator { - this.resetState(); - this._itemIndex.clear(); + clearStateAndCache() { + super.clearStateAndCache(); this.clearUnreadSnapshot(); - this.clearMessageFocusSignal(); - }; - - /** - * Partial truncation for `channel.truncated` carrying a `truncated_at`: drop every loaded - * message strictly older than the cutoff (keeping newer ones) across all loaded windows, - * mirroring the legacy per-message-set pruning. For a full truncation (no `truncated_at`) use - * {@link MessagePaginator.clearStateAndCache} instead. - * - * Batched and edge-classified: because messages are chronological, each interval is classified by - * its `tail` (oldest) and `head` (newest) edges, so only the single interval that *straddles* the - * cutoff is scanned member-by-member — the rest are kept or dropped wholesale. The straddling - * interval becomes the new global tail (nothing older than the cutoff exists anymore), so its - * `isTail`/`hasMoreTail` are set; intervals entirely newer keep their flags (unloaded older - * messages may still sit between them and the cutoff). The active window is re-emitted once. - */ - truncate = ({ truncatedAt }: { truncatedAt: Date }) => { - const cutoff = truncatedAt.getTime(); - if (Number.isNaN(cutoff)) return; - - const isOld = (item: LocalMessage | undefined) => { - const time = item?.created_at ? new Date(item.created_at).getTime() : undefined; - return typeof time === 'number' && time < cutoff; - }; - - const removedIds: string[] = []; - const survivingIntervals: AnyInterval[] = []; - // iterate from head to tail - for (const interval of this.itemIntervals) { - const edges = this.getIntervalPaginationEdges(interval); - if (!edges || !isOld(edges.tail)) { - survivingIntervals.push(interval); // oldest edge >= cutoff → nothing to drop - } else if (isOld(edges.head)) { - removedIds.push(...interval.itemIds); // newest edge < cutoff → whole interval is older - } else { - // determines the cutoff. Items are chronological, so the old ones are at the beginning of the array, - // binary-search rather than scanning every member. - const ids = interval.itemIds; - const splitIndex = lowerBound( - ids.length, - (index) => !isOld(this._itemIndex.get(ids[index])), - ); - removedIds.push(...ids.slice(0, splitIndex)); - const kept = ids.slice(splitIndex); - survivingIntervals.push( - isLogicalInterval(interval) - ? { ...interval, itemIds: kept } - : { ...interval, itemIds: kept, isTail: true, hasMoreTail: false }, - ); - } - } - - if (!removedIds.length) return; - for (const id of removedIds) this._itemIndex.remove(id); - - // No re-sort needed: `survivingIntervals` preserves the order of the already-sorted - // `itemIntervals` (we only keep/prune/drop, never reorder), and truncation removes only - // tailward items — an interval's head edge (the intervalComparator sort key) never changes. - this.setIntervals(survivingIntervals); - - // Single re-emit of the active window (setIntervals does not emit). - const active = this._activeIntervalId - ? this._itemIntervals.get(this._activeIntervalId) - : undefined; - if (active && !isLogicalInterval(active)) { - this.setActiveInterval(active); - return; - } - - // The active window was truncated away entirely. It sat below the cutoff, so it was older - // than every survivor — activate the nearest surviving window (the tail-most, i.e. oldest, - // anchored interval) rather than emitting an empty page, which would blank the message list. - const anchoredSurvivors = this.itemIntervals.filter( - (itv): itv is Interval => !isLogicalInterval(itv), - ); - - // If the active interval was truncated, we move to the neareast interval - which is the tail now - const fallback = this.getTailIntervalFromSortedIntervals(anchoredSurvivors); - if (fallback) { - this.setActiveInterval(fallback); - } else { - // Nothing loaded survived the truncation. - this.state.partialNext({ items: [] }); - } - }; - - applyMessageDeletionForUser = ({ - userId, - hardDelete = false, - deletedAt, - }: { - userId: string; - hardDelete?: boolean; - deletedAt: Date; - }) => { - const loadedMessages = this.items ?? []; - - for (const message of loadedMessages) { - if (message.user?.id === userId) { - if (hardDelete) { - this.removeItem({ id: message.id }); - } else { - this.ingestItem( - toDeletedMessage({ - message, - hardDelete, - deletedAt, - }) as LocalMessage, - ); - } - continue; - } - - if ( - message.quoted_message?.user?.id === userId && - message.quoted_message.type !== 'deleted' - ) { - this.ingestItem({ - ...message, - quoted_message: toDeletedMessage({ - message: message.quoted_message, - hardDelete, - deletedAt, - }) as LocalMessage, - }); - } - } - }; - - /** - * Ensures quoted-message snapshots across loaded paginator cache are in sync - * with the provided message. - * - * Scans cached messages and updates any item where `quoted_message_id` - * matches `message.id`. - */ - reflectQuotedMessageUpdate = (message: LocalMessage) => { - const cachedMessages = this._itemIndex.values(); - - for (const cachedMessage of cachedMessages) { - if (cachedMessage.quoted_message_id !== message.id) continue; - - this.ingestItem({ - ...cachedMessage, - quoted_message: message, - }); - } - }; - - /** - * Reflect an updated `user` object onto every cached message authored by that user, mirroring - * the legacy `ChannelState.updateUserMessages` for the main message list (does not touch - * `quoted_message.user` — that is not part of the legacy behavior). - * - * Batched: a user rename can affect many messages, so this patches the shared item index and - * re-emits the active window a single time (if it held an affected message) rather than one - * `ingestItem` re-emit per message. - */ - reflectUserUpdate = (user: UserResponse) => { - const activeIds = new Set((this.items ?? []).map((m) => this.getItemId(m))); - let activeAffected = false; - for (const message of this._itemIndex.values()) { - if (message.user?.id !== user.id) continue; - this._itemIndex.setOne({ ...message, user }); - if (activeIds.has(this.getItemId(message))) activeAffected = true; - } - if (activeAffected) { - this.state.partialNext({ - items: (this.items ?? []).map((m) => this.getItem(this.getItemId(m)) ?? m), - }); - } - }; - - /** - * Apply a reaction WS event (`reaction.new` / `reaction.updated` / `reaction.deleted`) to the - * cached message. The event's `message` already carries the server-updated - * `reaction_groups` / `latest_reactions`; only `own_reactions` needs local preservation so a - * cross-user reaction does not wipe the current user's reactions. This re-homes what - * `ChannelState.addReaction` / `removeReaction` used to do off the now-removed - * `channel.state.messages` / `channel.state.threads` caches (the same logic backs the thread - * paginator via `Thread.messagePaginator`). - * - * `own_reactions` is seeded from the currently cached item (so another user's reaction keeps ours), - * falling back to the event's own_reactions when the message is not loaded — matching the legacy - * behavior where `_updateMessage` only mutated a message that existed locally. - * - * @param params - * @param {MessageResponse | LocalMessage} params.message The reaction event's message, carrying the - * server-computed `reaction_groups` / `latest_reactions`. Ingested as-is except for `own_reactions`. - * @param {ReactionResponse} params.reaction The reaction from the event. Only added to/removed from - * `own_reactions` when its `user_id` is the current user; otherwise the current user's - * `own_reactions` are left untouched. - * @param {boolean} [params.removed=false] `true` for `reaction.deleted` (remove the reaction from - * `own_reactions`); `false` for `reaction.new` / `reaction.updated` (add it). - * @param {boolean} [params.enforceUnique=false] When adding, first clear the current user's existing - * `own_reactions` so only the incoming one remains (used by `reaction.updated`, where a user's - * reaction replaces their previous one). - */ - reflectReaction = ({ - enforceUnique = false, - message, - reaction, - removed = false, - }: { - message: MessageResponse | LocalMessage; - reaction: ReactionResponse; - enforceUnique?: boolean; - removed?: boolean; - }) => { - const formatted = formatMessage(message); - const existing = this.getItem(formatted.id); - const baseOwnReactions = existing?.own_reactions ?? formatted.own_reactions ?? []; - const own_reactions = removed - ? this.removeOwnReactionOfType(baseOwnReactions, reaction) - : this.addOwnReaction(baseOwnReactions, reaction, enforceUnique); - this.ingestItem({ ...formatted, own_reactions }); - }; - - private removeOwnReactionOfType( - ownReactions: ReactionResponse[], - reaction: ReactionResponse, - ): ReactionResponse[] { - return ownReactions.filter( - (r) => r.user_id !== reaction.user_id || r.type !== reaction.type, - ); - } - - private addOwnReaction( - ownReactions: ReactionResponse[], - reaction: ReactionResponse, - enforceUnique: boolean, - ): ReactionResponse[] { - const base = enforceUnique - ? [] - : this.removeOwnReactionOfType(ownReactions, reaction); - if (this.channel.getClient().userID === reaction.user_id) { - return [...base, reaction]; - } - return base; - } - - /** - * Map a timestamp to a loaded message — the first message in the latest (head) window whose - * `created_at` is >= `timestampMs` (mirrors the legacy `ChannelState.findMessageByTimestamp` - * lower-bound search), or the newest loaded message when the timestamp is beyond it. Used by the - * receipts tracker to resolve read/delivered cursors. Searches the newest loaded window — where - * read cursors live — which is already sorted, so this is O(log n) with no re-sort. - */ - findItemByTimestamp = ( - timestampMs: number, - exactTsMatch = false, - ): LocalMessage | null => { - const items = this.latestItems; // ascending by created_at - if (!items.length) return null; - // Resolve the last message created AT OR BEFORE `timestampMs` (floor). The sole caller is - // read/delivered cursor resolution (MessageReceiptsTracker): the cursor carries the timestamp of - // the last message a participant reached, so a message created strictly after the cursor has NOT - // been reached. A ceil match (first message >= target) would over-count it — e.g. a participant - // whose read cursor predates every loaded message would be reported as having read the oldest one. - // `lowerBound` returns the first index whose created_at is strictly greater than the target, so - // the floor is the item immediately before it. - const firstAfter = lowerBound(items.length, (i) => { - const t = getMessageCreatedAtTimestamp(items[i]); - return t === null || t > timestampMs; - }); - if (firstAfter === 0) return null; // target precedes every loaded message - const found = items[firstAfter - 1]; - const foundTimestamp = getMessageCreatedAtTimestamp(found); - // A message without a resolvable created_at (e.g. an optimistic message still missing its - // server timestamp) cannot be located by timestamp. - if (foundTimestamp === null) return null; - if (!exactTsMatch) return found; - return foundTimestamp === timestampMs ? found : null; - }; - - filterQueryResults = (items: LocalMessage[]) => - items.filter(this.shouldIncludeMessageInInterval.bind(this)); - - private getCanonicalQueryItems(items: LocalMessage[]): LocalMessage[] { - return [...items].sort(this.itemOrderComparator); } } - -const makeDeriveCursor = - (paginator: MessagePaginator): CursorDerivator => - (ctx) => { - // Not included in the interval (filtered out by MessagePaginator.filterQueryResults). - // - // IMPORTANT: We must keep cursor derivation consistent with the ingested interval. - // The interval is built from the filtered page, but ctx.page contains the raw response. - // Around/linear derivators compare page edges and lengths against interval.itemIds. If we - // pass a page that includes locally filtered messages (e.g. shadowed), those comparisons - // can incorrectly conclude that the page is not at the dataset bounds. - const pageWithPermittedMessages: LocalMessage[] = []; - let filteredLocallyCount = 0; - for (const message of ctx.page) { - if (!paginator.shouldIncludeMessageInInterval(message)) { - filteredLocallyCount++; - } else { - pageWithPermittedMessages.push(message); - } - } - - const requestedPageSizeAfterAdjustment = Math.max( - 0, - ctx.requestedPageSize - filteredLocallyCount, - ); - - if ( - ctx.interval && - ctx.interval.itemIds.length + filteredLocallyCount < ctx.page.length - ) { - console.error( - 'error', - 'Corrupted message set state: parent set size < returned page size', - ); - return { - cursor: ctx.cursor, - hasMoreHead: ctx.hasMoreHead, - hasMoreTail: ctx.hasMoreTail, - }; - } - - const injectCursor = ({ - hasMoreHead, - hasMoreTail, - }: { - hasMoreHead: boolean; - hasMoreTail: boolean; - }): CursorDeriveResult => { - const cursor: PaginatorCursor = { - headward: !hasMoreHead ? null : (ctx.interval?.itemIds.slice(-1)[0] ?? null), - tailward: !hasMoreTail ? null : (ctx.interval?.itemIds[0] ?? null), - }; - return { cursor, hasMoreHead, hasMoreTail }; - }; - - if ((ctx.queryShape as MessagePaginationOptions)?.created_at_around) { - return injectCursor( - deriveCreatedAtAroundPaginationFlags< - LocalMessage, - MessagePaginationOptions, - MessagePaginator - >({ - ...ctx, - paginator, - page: pageWithPermittedMessages, - requestedPageSize: requestedPageSizeAfterAdjustment, - }), - ); - } else if (ctx.queryShape?.id_around) { - return injectCursor( - deriveIdAroundPaginationFlags({ - ...ctx, - page: pageWithPermittedMessages, - requestedPageSize: requestedPageSizeAfterAdjustment, - }), - ); - } else { - return injectCursor( - deriveLinearPaginationFlags({ - ...ctx, - page: pageWithPermittedMessages, - requestedPageSize: requestedPageSizeAfterAdjustment, - }), - ); - } - }; diff --git a/src/pagination/paginators/index.ts b/src/pagination/paginators/index.ts index 8a6b8ff39..04c49b6ce 100644 --- a/src/pagination/paginators/index.ts +++ b/src/pagination/paginators/index.ts @@ -1,5 +1,6 @@ export * from './BasePaginator'; export * from './ChannelPaginator'; +export { MessageIntervalPaginator } from './MessageIntervalPaginator'; export * from './MessagePaginator'; export * from './MessageReplyPaginator'; export * from './ReminderPaginator'; From ecd84fa516515b7a3130c3e1d2085f98c08d3706 Mon Sep 17 00:00:00 2001 From: martincupela Date: Mon, 20 Jul 2026 17:13:33 +0200 Subject: [PATCH 12/25] feat(pagination): add PinnedMessagePaginator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New PinnedMessagePaginator extends the unread-free MessageIntervalPaginator base, so it never carries the read/unread surface (pinned messages are a subset of the channel and must not participate in read/delivery tracking). It fetches from the /pinned_messages endpoint via config.doRequest, orders by pinned_at ascending, filters on { cid, pinned: true } (so ingestItem auto-adds on pin and auto-removes on unpin), and includes only pinned, non-shadowed messages on the query path. jumpToMessage/reflect*/truncate/applyMessageDeletionForUser are inherited. Not yet wired into Channel — purely additive. Co-Authored-By: Claude Opus 4.8 --- .../paginators/PinnedMessagePaginator.ts | 104 +++++++++++++++++ src/pagination/paginators/index.ts | 1 + .../paginators/PinnedMessagePaginator.test.ts | 110 ++++++++++++++++++ 3 files changed, 215 insertions(+) create mode 100644 src/pagination/paginators/PinnedMessagePaginator.ts create mode 100644 test/unit/pagination/paginators/PinnedMessagePaginator.test.ts diff --git a/src/pagination/paginators/PinnedMessagePaginator.ts b/src/pagination/paginators/PinnedMessagePaginator.ts new file mode 100644 index 000000000..484521b73 --- /dev/null +++ b/src/pagination/paginators/PinnedMessagePaginator.ts @@ -0,0 +1,104 @@ +import type { PaginatorCursor, PaginatorOptions } from './BasePaginator'; +import { + MessageIntervalPaginator, + type MessageQueryShape, +} from './MessageIntervalPaginator'; +import type { AscDesc, LocalMessage, PinnedMessagePaginationOptions } from '../../types'; +import type { Channel } from '../../channel'; +import { formatMessage, generateUUIDv4 } from '../../utils'; +import { makeComparator } from '../sortCompiler'; +import { resolveDotPathValue } from '../utility.normalization'; +import { ItemIndex } from '../ItemIndex'; + +export type PinnedMessagePaginatorFilter = { + cid: string; + pinned: boolean; +}; + +export type PinnedMessagePaginatorOptions = { + channel: Channel; + id?: string; + itemIndex?: ItemIndex; + paginatorOptions?: PaginatorOptions; +}; + +/** + * Pinned-message list paginator. + * + * Extends the unread-free {@link MessageIntervalPaginator} base — pinned messages are a subset of + * the channel and MUST NOT participate in read/unread or delivery-receipt tracking (that belongs to + * the channel and thread message timelines). By extending the base rather than {@link MessagePaginator} + * it simply never gets the unread surface. + * + * Differences from the main list: + * - fetches from the `/pinned_messages` endpoint (`channel.getPinnedMessages`) rather than + * `channel.query({ messages })`; + * - includes only pinned, non-shadowed messages (`shouldIncludeMessageInInterval`), and filters on + * `{ cid, pinned: true }` so `ingestItem` auto-adds on pin and auto-removes on unpin; + * - orders by `pinned_at` ascending (oldest-pinned first), matching the legacy + * `channel.state.pinnedMessages` order. + * + * Navigation (`jumpToMessage` / `jumpToTheLatestMessage`) is inherited and meaningful — the endpoint + * supports `id_around` — but no unread-coupled navigation exists. + */ +export class PinnedMessagePaginator extends MessageIntervalPaginator { + constructor({ + channel, + id, + itemIndex, + paginatorOptions, + }: PinnedMessagePaginatorOptions) { + super({ + channel, + id: id ?? `pinned-message-paginator-${generateUUIDv4()}`, + itemIndex: itemIndex ?? new ItemIndex({ getId: (item) => item.id }), + paginatorOptions, + }); + + // Order by pinned_at (ascending), overriding the base's created_at comparators. Ascending keeps + // the head edge (most-recently-pinned) at the end of an interval, matching the base's interval + // direction getters (which are shared with created_at-asc semantics). + const tiebreaker = (l: LocalMessage, r: LocalMessage) => { + const leftId = this.getItemId(l); + const rightId = this.getItemId(r); + return leftId < rightId ? -1 : leftId > rightId ? 1 : 0; + }; + const pinnedAtSort: { pinned_at: AscDesc } = { pinned_at: 1 }; + this.sortComparator = makeComparator({ + sort: pinnedAtSort, + resolvePathValue: resolveDotPathValue, + tiebreaker, + }); + this.config.itemOrderComparator = makeComparator< + LocalMessage, + { pinned_at: AscDesc } + >({ + sort: pinnedAtSort, + resolvePathValue: resolveDotPathValue, + tiebreaker, + }); + + // Fetch from the pinned-messages endpoint. The base `query` feeds the resolved query shape + // (including `id_around` jumps) here as `options`; we return both cursors and let the base gate + // them by direction. + this.config.doRequest = async ( + options: MessageQueryShape, + ): Promise<{ cursor?: PaginatorCursor; items: LocalMessage[] }> => { + const { messages } = await this.channel.getPinnedMessages( + options as PinnedMessagePaginationOptions, + [{ pinned_at: 1 }], + ); + const items = messages.map(formatMessage); + return { cursor: this.getCursorFromQueryResults({ items }), items }; + }; + } + + buildFilters = (): PinnedMessagePaginatorFilter => ({ + cid: this.channel.cid, + pinned: true, + }); + + shouldIncludeMessageInInterval(message: LocalMessage): boolean { + return !message.shadowed && !!message.pinned; + } +} diff --git a/src/pagination/paginators/index.ts b/src/pagination/paginators/index.ts index 04c49b6ce..217003249 100644 --- a/src/pagination/paginators/index.ts +++ b/src/pagination/paginators/index.ts @@ -3,5 +3,6 @@ export * from './ChannelPaginator'; export { MessageIntervalPaginator } from './MessageIntervalPaginator'; export * from './MessagePaginator'; export * from './MessageReplyPaginator'; +export * from './PinnedMessagePaginator'; export * from './ReminderPaginator'; export * from './UserGroupPaginator'; diff --git a/test/unit/pagination/paginators/PinnedMessagePaginator.test.ts b/test/unit/pagination/paginators/PinnedMessagePaginator.test.ts new file mode 100644 index 000000000..a4c7fc220 --- /dev/null +++ b/test/unit/pagination/paginators/PinnedMessagePaginator.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it, vi } from 'vitest'; +import { PinnedMessagePaginator } from '../../../../src/pagination/paginators/PinnedMessagePaginator'; +import type { LocalMessage, MessageResponse } from '../../../../src/types'; + +const CID = 'messaging:cid'; + +const makePinned = ( + id: string, + pinnedAtMs: number, + overrides: Partial = {}, +): MessageResponse => + ({ + attachments: [], + cid: CID, + created_at: new Date(pinnedAtMs).toISOString(), + id, + mentioned_users: [], + pinned: true, + pinned_at: new Date(pinnedAtMs).toISOString(), + status: 'received', + text: id, + type: 'regular', + updated_at: new Date(pinnedAtMs).toISOString(), + ...overrides, + }) as MessageResponse; + +const makeChannel = (getPinnedMessages = vi.fn()) => + ({ + cid: CID, + getClient: () => ({ + notifications: { addError: vi.fn() }, + userID: 'me', + }), + getPinnedMessages, + }) as unknown as import('../../../../src/channel').Channel; + +describe('PinnedMessagePaginator', () => { + it('fetches from getPinnedMessages and orders by pinned_at ascending', async () => { + const getPinnedMessages = vi.fn().mockResolvedValue({ + messages: [makePinned('c', 3000), makePinned('a', 1000), makePinned('b', 2000)], + }); + const paginator = new PinnedMessagePaginator({ + channel: makeChannel(getPinnedMessages), + }); + + await paginator.executeQuery(); + + expect(getPinnedMessages).toHaveBeenCalledTimes(1); + expect(paginator.items?.map((m) => m.id)).toEqual(['a', 'b', 'c']); + }); + + it('excludes non-pinned and shadowed messages from the queried page', async () => { + const getPinnedMessages = vi.fn().mockResolvedValue({ + messages: [ + makePinned('p', 1000), + makePinned('u', 2000, { pinned: false, pinned_at: null }), + makePinned('s', 3000, { shadowed: true }), + ], + }); + const paginator = new PinnedMessagePaginator({ + channel: makeChannel(getPinnedMessages), + }); + + await paginator.executeQuery(); + + expect(paginator.items?.map((m) => m.id)).toEqual(['p']); + }); + + it('auto-removes a message from the active window when it is unpinned', async () => { + const getPinnedMessages = vi + .fn() + .mockResolvedValue({ messages: [makePinned('p', 1000)] }); + const paginator = new PinnedMessagePaginator({ + channel: makeChannel(getPinnedMessages), + }); + + await paginator.executeQuery(); + expect(paginator.items?.map((m) => m.id)).toEqual(['p']); + + // Same message, now unpinned → matchesFilter({ pinned: true }) fails → removed from the list. + paginator.ingestItem({ + ...makePinned('p', 1000), + created_at: new Date(1000), + pinned: false, + pinned_at: null, + } as unknown as LocalMessage); + expect(paginator.items?.map((m) => m.id)).toEqual([]); + }); + + it('does not expose the unread / live-view surface (never coupled to read state)', () => { + const paginator = new PinnedMessagePaginator({ channel: makeChannel() }); + const surface = paginator as unknown as Record; + + expect(surface.unreadStateSnapshot).toBeUndefined(); + expect(surface.liveViewState).toBeUndefined(); + expect(surface.seedUnreadSnapshot).toBeUndefined(); + expect(surface.setUnreadSnapshot).toBeUndefined(); + expect(surface.clearUnreadSnapshot).toBeUndefined(); + expect(surface.setViewingLive).toBeUndefined(); + expect(surface.isViewingLive).toBeUndefined(); + expect(surface.jumpToTheFirstUnreadMessage).toBeUndefined(); + }); + + it('retains message-interval navigation (jumpToMessage is inherited)', () => { + const paginator = new PinnedMessagePaginator({ channel: makeChannel() }); + expect(typeof paginator.jumpToMessage).toBe('function'); + expect(typeof paginator.jumpToTheLatestMessage).toBe('function'); + expect(typeof paginator.reflectReaction).toBe('function'); + }); +}); From 031c5cace195fb7a5fe60d0108971a509cff871d Mon Sep 17 00:00:00 2001 From: martincupela Date: Tue, 21 Jul 2026 09:31:04 +0200 Subject: [PATCH 13/25] feat(channel): populate channel.pinnedMessagesPaginator from events (parity) Create channel.pinnedMessagesPaginator, seed it from state.pinned_messages in _initializeState, and feed it alongside the legacy channel.state.pinnedMessages at every mutation site: message.new/ message.updated (ingestItem auto-adds on pin / auto-removes on unpin), message.deleted, channel. truncated, channel.hidden clear_history, the reaction events (reflectReaction), and the channel- and client-level user.messages.deleted/user.deleted (applyMessageDeletionForUser). Parity-only: both stores run in parallel; nothing reads the paginator yet. Readers migrate and the legacy store is removed in the next step. Co-Authored-By: Claude Opus 4.8 --- src/channel.ts | 41 ++++++++++++++++++++++++++++++++++++++- src/client.ts | 5 +++++ test/unit/channel.test.js | 39 +++++++++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 1 deletion(-) diff --git a/src/channel.ts b/src/channel.ts index 4002a7768..785d0ddc4 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -4,7 +4,7 @@ import { CooldownTimer } from './CooldownTimer'; import { MessageComposer } from './messageComposer'; import { MessageReceiptsTracker } from './messageDelivery'; import type { ReadStoreReconcileMeta } from './messageDelivery'; -import { MessagePaginator } from './pagination/paginators'; +import { MessagePaginator, PinnedMessagePaginator } from './pagination/paginators'; import { MessageOperations } from './messageOperations'; import { channelHasReadEvents, @@ -189,6 +189,7 @@ export class Channel { public readonly messageComposer: MessageComposer; public readonly messageReceiptsTracker: MessageReceiptsTracker; public readonly messagePaginator: MessagePaginator; + public readonly pinnedMessagesPaginator: PinnedMessagePaginator; public readonly messageOperations: MessageOperations; public readonly cooldownTimer: CooldownTimer; @@ -244,6 +245,7 @@ export class Channel { // (receipts resolve read cursors via findItemByTimestamp; CooldownTimer.refresh reads the // latest window at construction). this.messagePaginator = new MessagePaginator({ channel: this }); + this.pinnedMessagesPaginator = new PinnedMessagePaginator({ channel: this }); this.messageReceiptsTracker = new MessageReceiptsTracker({ channel: this }); this.messageReceiptsTracker.registerSubscriptions(); @@ -2376,11 +2378,14 @@ export class Channel { if (!isThreadReply) { if (event.hard_delete) { this.messagePaginator.removeItem({ id: event.message.id }); + this.pinnedMessagesPaginator.removeItem({ id: event.message.id }); } else { this.messagePaginator.ingestItem(formattedMessage); + this.pinnedMessagesPaginator.ingestItem(formattedMessage); } } this.messagePaginator.reflectQuotedMessageUpdate(formattedMessage); + this.pinnedMessagesPaginator.reflectQuotedMessageUpdate(formattedMessage); if (event.message.pinned) { channelState.removePinnedMessage(event.message); @@ -2396,6 +2401,11 @@ export class Channel { hardDelete, deletedAt, }); + this.pinnedMessagesPaginator.applyMessageDeletionForUser({ + userId: event.user.id, + hardDelete, + deletedAt, + }); this.state.deleteUserMessages(event.user, hardDelete, deletedAt); } @@ -2418,6 +2428,8 @@ export class Channel { if (!isThreadMessage) { this.messagePaginator.ingestItem(formatMessage(event.message)); + // ingestItem auto-adds when pinned (matchesFilter { pinned: true }). + this.pinnedMessagesPaginator.ingestItem(formatMessage(event.message)); } // do not increase the unread count - the back-end does not increase the count neither in the following cases: @@ -2492,6 +2504,9 @@ export class Channel { if (!event.message.parent_id) { this.messagePaginator.ingestItem(formattedMessage); this.messagePaginator.reflectQuotedMessageUpdate(formattedMessage); + // ingestItem auto-adds on pin / auto-removes on unpin (matchesFilter { pinned: true }). + this.pinnedMessagesPaginator.ingestItem(formattedMessage); + this.pinnedMessagesPaginator.reflectQuotedMessageUpdate(formattedMessage); } if (event.message.pinned) { channelState.addPinnedMessage(event.message); @@ -2516,16 +2531,19 @@ export class Channel { // too (clearStateAndCache did this for the full-truncate branch). this.messagePaginator.truncate({ truncatedAt: truncatedAtDate }); this.messagePaginator.clearUnreadSnapshot(); + this.pinnedMessagesPaginator.truncate({ truncatedAt: truncatedAtDate }); } else { channelState.clearMessages(); channelState.unreadCount = 0; this.messagePaginator.clearStateAndCache(); + this.pinnedMessagesPaginator.clearStateAndCache(); } // system messages don't increment unread counts if (event.message) { channelState.addMessageSorted(event.message); this.messagePaginator.ingestItem(formatMessage(event.message)); + this.pinnedMessagesPaginator.ingestItem(formatMessage(event.message)); if (event.message.pinned) { channelState.addPinnedMessage(event.message); } @@ -2637,6 +2655,10 @@ export class Channel { event.message = channelState.addReaction(reaction, message) as MessageResponse; if (!event.message?.parent_id) { this.messagePaginator.reflectReaction({ message: event.message, reaction }); + this.pinnedMessagesPaginator.reflectReaction({ + message: event.message, + reaction, + }); } } break; @@ -2650,6 +2672,11 @@ export class Channel { reaction, removed: true, }); + this.pinnedMessagesPaginator.reflectReaction({ + message: event.message, + reaction, + removed: true, + }); } } break; @@ -2668,6 +2695,11 @@ export class Channel { message: event.message, reaction, }); + this.pinnedMessagesPaginator.reflectReaction({ + enforceUnique: true, + message: event.message, + reaction, + }); } } break; @@ -2682,6 +2714,7 @@ export class Channel { if (event.clear_history) { channelState.clearMessages(); this.messagePaginator.clearStateAndCache(); + this.pinnedMessagesPaginator.clearStateAndCache(); } break; } @@ -2802,6 +2835,12 @@ export class Channel { this.state.pinnedMessages = []; } this.state.addPinnedMessages(state.pinned_messages || []); + // Seed the pinned-messages paginator from the same response (runs in parallel with the legacy + // channel.state.pinnedMessages until that store is removed). + this.pinnedMessagesPaginator.seedFirstPageSync( + (state.pinned_messages || []).map(formatMessage), + this.pinnedMessagesPaginator.pageSize, + ); if (state.pending_messages) { this.state.pending_messages = state.pending_messages; } diff --git a/src/client.ts b/src/client.ts index 48d7d99a3..2b327f063 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1520,6 +1520,11 @@ export class StreamChat { hardDelete, deletedAt: deletedAt ?? new Date(), }); + channel.pinnedMessagesPaginator.applyMessageDeletionForUser({ + userId: user.id, + hardDelete, + deletedAt: deletedAt ?? new Date(), + }); state?.deleteUserMessages(user, hardDelete, deletedAt); } } diff --git a/test/unit/channel.test.js b/test/unit/channel.test.js index 176854d90..2a8b9d316 100644 --- a/test/unit/channel.test.js +++ b/test/unit/channel.test.js @@ -435,6 +435,45 @@ describe('Channel _handleChannelEvent', function () { channel.initialized = true; }); + it('feeds the pinnedMessagesPaginator on pin and unpin events', () => { + const existing = generateMsg({ + id: 'pinned-existing', + cid: channel.cid, + pinned: true, + pinned_at: new Date('2020-01-01T00:00:00.001Z').toISOString(), + }); + channel.pinnedMessagesPaginator.ingestPage({ + page: [formatMessage(existing)], + isHead: true, + isTail: true, + setActive: true, + }); + expect(channel.pinnedMessagesPaginator.items?.map((m) => m.id)).to.eql([ + 'pinned-existing', + ]); + + // A newly pinned message arrives → auto-added. + const newlyPinned = generateMsg({ + id: 'pinned-new', + cid: channel.cid, + pinned: true, + pinned_at: new Date('2020-01-01T00:00:00.002Z').toISOString(), + }); + channel._handleChannelEvent({ type: 'message.new', message: newlyPinned, user }); + expect(channel.pinnedMessagesPaginator.items?.map((m) => m.id)).to.include( + 'pinned-new', + ); + + // The existing message is unpinned via message.updated → auto-removed. + channel._handleChannelEvent({ + type: 'message.updated', + message: { ...existing, pinned: false, pinned_at: null }, + }); + expect(channel.pinnedMessagesPaginator.items?.map((m) => m.id)).to.not.include( + 'pinned-existing', + ); + }); + it('member.updated/member.added are being handled properly (ChannelState.membership & ChannelState.members)', () => { expect(channel.state.members).to.be.empty; expect(channel.state.membership).to.be.empty; From c94b2a4a4e10a5b6b32c34010fe7404335670584 Mon Sep 17 00:00:00 2001 From: martincupela Date: Tue, 21 Jul 2026 11:06:27 +0200 Subject: [PATCH 14/25] refactor(channel_state): remove channel.state.pinnedMessages and pinned-only methods The pinned-message list is now owned by channel.pinnedMessagesPaginator. Delete channel.state .pinnedMessages plus addPinnedMessage(s)/removePinnedMessage and the now-orphaned pinned-only methods (addReaction/removeReaction, _updateMessage, updateUserMessages, deleteUserMessages, _addToMessageList, removeMessageFromArray, clearMessages), and the orphaned utils.addToMessageList / utils.deleteUserMessages helpers. ChannelState now holds no message/thread/pinned storage. Per-user pinned updates re-home to pinnedMessagesPaginator.reflectUserUpdate; user deletions to applyMessageDeletionForUser (channel + client level). channel.ts/client.ts drop the legacy calls; the paginator feeds added earlier are the sole handling. Tests: reorganize channel.ts _handleChannelEvent tests into one describe per WS event, each asserting the effect on messagePaginator, pinnedMessagesPaginator and channel state (test bodies moved verbatim, no assertion lost). Add parity coverage for the pinned paginator on channel WS events (truncate/delete/user-deletion soft+hard/reaction) and client-level cross-channel user deletion (cid-guard, soft, hard, main + pinned) and user.updated propagation. BREAKING CHANGE: channel.state.pinnedMessages and ChannelState.addPinnedMessage(s)/ removePinnedMessage/addReaction/removeReaction/updateUserMessages/deleteUserMessages are removed. Read pinned messages from channel.pinnedMessagesPaginator. utils.addToMessageList and utils.deleteUserMessages are removed. Co-Authored-By: Claude Opus 4.8 --- src/channel.ts | 48 +- src/channel_state.ts | 258 +---- src/client.ts | 13 +- src/utils.ts | 99 -- test/unit/channel.test.js | 1931 +++++++++++++++---------------- test/unit/channel_state.test.js | 241 ---- test/unit/client.test.js | 274 ++--- test/unit/utils.test.ts | 143 +-- 8 files changed, 1065 insertions(+), 1942 deletions(-) diff --git a/src/channel.ts b/src/channel.ts index 785d0ddc4..4e3119cd4 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -2386,10 +2386,6 @@ export class Channel { } this.messagePaginator.reflectQuotedMessageUpdate(formattedMessage); this.pinnedMessagesPaginator.reflectQuotedMessageUpdate(formattedMessage); - - if (event.message.pinned) { - channelState.removePinnedMessage(event.message); - } } break; case 'user.messages.deleted': @@ -2406,8 +2402,6 @@ export class Channel { hardDelete, deletedAt, }); - - this.state.deleteUserMessages(event.user, hardDelete, deletedAt); } break; case 'message.new': @@ -2422,10 +2416,6 @@ export class Channel { channelState.addMessageSorted(event.message, ownMessage); } - if (event.message.pinned) { - channelState.addPinnedMessage(event.message); - } - if (!isThreadMessage) { this.messagePaginator.ingestItem(formatMessage(event.message)); // ingestItem auto-adds when pinned (matchesFilter { pinned: true }). @@ -2508,22 +2498,12 @@ export class Channel { this.pinnedMessagesPaginator.ingestItem(formattedMessage); this.pinnedMessagesPaginator.reflectQuotedMessageUpdate(formattedMessage); } - if (event.message.pinned) { - channelState.addPinnedMessage(event.message); - } else { - channelState.removePinnedMessage(event.message); - } } break; case 'channel.truncated': if (event.channel?.truncated_at) { const truncatedAtDate = new Date(event.channel.truncated_at); - const truncatedAt = +truncatedAtDate; - channelState.pinnedMessages.forEach(({ id, created_at: createdAt }) => { - if (truncatedAt > +createdAt) - channelState.removePinnedMessage({ id } as MessageResponse); - }); channelState.unreadCount = this.countUnread(truncatedAtDate); // Partial truncation: keep messages newer than the cutoff. clearStateAndCache would wipe // the whole paginator (readers now source from it), so use the partial truncate. The @@ -2533,7 +2513,6 @@ export class Channel { this.messagePaginator.clearUnreadSnapshot(); this.pinnedMessagesPaginator.truncate({ truncatedAt: truncatedAtDate }); } else { - channelState.clearMessages(); channelState.unreadCount = 0; this.messagePaginator.clearStateAndCache(); this.pinnedMessagesPaginator.clearStateAndCache(); @@ -2544,9 +2523,6 @@ export class Channel { channelState.addMessageSorted(event.message); this.messagePaginator.ingestItem(formatMessage(event.message)); this.pinnedMessagesPaginator.ingestItem(formatMessage(event.message)); - if (event.message.pinned) { - channelState.addPinnedMessage(event.message); - } } break; @@ -2648,11 +2624,7 @@ export class Channel { break; case 'reaction.new': if (event.message && event.reaction) { - const { message, reaction } = event; - // channelState.addReaction still runs for its pinnedMessages side-effect (dropped in the - // storage-removal step); the paginator's own_reactions preservation is now re-homed onto - // messagePaginator.reflectReaction (thread replies are handled by the Thread object). - event.message = channelState.addReaction(reaction, message) as MessageResponse; + const { reaction } = event; if (!event.message?.parent_id) { this.messagePaginator.reflectReaction({ message: event.message, reaction }); this.pinnedMessagesPaginator.reflectReaction({ @@ -2664,8 +2636,7 @@ export class Channel { break; case 'reaction.deleted': if (event.message && event.reaction) { - const { message, reaction } = event; - event.message = channelState.removeReaction(reaction, message); + const { reaction } = event; if (event.message && !event.message.parent_id) { this.messagePaginator.reflectReaction({ message: event.message, @@ -2682,13 +2653,8 @@ export class Channel { break; case 'reaction.updated': if (event.message && event.reaction) { - const { message, reaction } = event; + const { reaction } = event; // assuming reaction.updated is only called if enforce_unique is true - event.message = channelState.addReaction( - reaction, - message, - true, - ) as MessageResponse; if (!event.message?.parent_id) { this.messagePaginator.reflectReaction({ enforceUnique: true, @@ -2712,7 +2678,6 @@ export class Channel { }; channel._syncStateFromChannelData(channel.data, previousChannelData); if (event.clear_history) { - channelState.clearMessages(); this.messagePaginator.clearStateAndCache(); this.pinnedMessagesPaginator.clearStateAndCache(); } @@ -2831,12 +2796,7 @@ export class Channel { // cleanup, and advances last_message_at for the initializing page. this.state.addMessagesSorted(state.messages || [], false, true, true); - if (!this.state.pinnedMessages) { - this.state.pinnedMessages = []; - } - this.state.addPinnedMessages(state.pinned_messages || []); - // Seed the pinned-messages paginator from the same response (runs in parallel with the legacy - // channel.state.pinnedMessages until that store is removed). + // Seed the pinned-messages paginator from the same response. this.pinnedMessagesPaginator.seedFirstPageSync( (state.pinned_messages || []).map(formatMessage), this.pinnedMessagesPaginator.pageSize, diff --git a/src/channel_state.ts b/src/channel_state.ts index ff343a447..b21158bf2 100644 --- a/src/channel_state.ts +++ b/src/channel_state.ts @@ -6,14 +6,9 @@ import type { MessageResponse, MessageResponseBase, PendingMessageResponse, - ReactionResponse, UserResponse, } from './types'; -import { - deleteUserMessages as _deleteUserMessages, - addToMessageList, - formatMessage, -} from './utils'; +import { formatMessage } from './utils'; import { StateStore } from './store'; type ChannelReadStatus = Record< @@ -67,7 +62,6 @@ export class ChannelState { readonly ownCapabilitiesStore: StateStore; // todo: is this actually used somewhere? readonly mutedUsersStore: StateStore; - pinnedMessages: Array; pending_messages: Array; unreadCount: number; membership: ChannelMemberResponse; @@ -98,7 +92,6 @@ export class ChannelState { }); this.syncMemberCountFromChannelData(channel?.data); this.syncOwnCapabilitiesFromChannelData(channel?.data); - this.pinnedMessages = []; this.pending_messages = []; this.membership = {}; this.unreadCount = 0; @@ -351,172 +344,6 @@ export class ChannelState { } } - /** - * addPinnedMessages - adds messages in pinnedMessages property - * - * @param {Array} pinnedMessages A list of pinned messages - * - */ - addPinnedMessages(pinnedMessages: MessageResponse[]) { - for (let i = 0; i < pinnedMessages.length; i += 1) { - this.addPinnedMessage(pinnedMessages[i]); - } - } - - /** - * addPinnedMessage - adds message in pinnedMessages - * - * @param {MessageResponse} pinnedMessage message to update - * - */ - addPinnedMessage(pinnedMessage: MessageResponse) { - this.pinnedMessages = this._addToMessageList( - this.pinnedMessages, - this.formatMessage(pinnedMessage), - false, - 'pinned_at', - ); - } - - /** - * removePinnedMessage - removes pinned message from pinnedMessages - * - * @param {MessageResponse} message message to remove - * - */ - removePinnedMessage(message: MessageResponse) { - const { result } = this.removeMessageFromArray(this.pinnedMessages, message); - this.pinnedMessages = result; - } - - /** - * addReaction - keeps the pinned-message copy's reactions in sync and enriches the passed - * `event.message` with the current user's `own_reactions`. - * - * The channel message list is owned by `channel.messagePaginator` (see `reflectReaction`) and - * thread replies by the `Thread` object; this only maintains the pinned-message cache. - */ - addReaction( - reaction: ReactionResponse, - message?: MessageResponse, - enforce_unique?: boolean, - ) { - if (!message) { - return; - } - - const messageWithReaction = message; - const updateData = { - id: messageWithReaction.id, - parent_id: messageWithReaction.parent_id, - pinned: messageWithReaction.pinned, - show_in_channel: messageWithReaction.show_in_channel, - }; - - this._updateMessage(updateData, (msg) => { - const updatedMessage = { ...messageWithReaction }; - // This part will remove own_reactions from what is essentially - // a copy of event.message; we do not want to return that as someone - // else reaction would remove our own_reactions needlessly. This - // only happens when we are not the sender of the reaction. We need - // the variable itself so that the event can be properly enriched - // later on. - messageWithReaction.own_reactions = this._addOwnReactionToMessage( - msg.own_reactions, - reaction, - enforce_unique, - ); - // Whenever we are the ones sending the reaction, the helper enriches - // own_reactions as normal so we can use that, otherwise we fallback - // to whatever state we had. - updatedMessage.own_reactions = - this._channel.getClient().userID === reaction.user_id - ? messageWithReaction.own_reactions - : msg.own_reactions; - return this.formatMessage(updatedMessage); - }); - return messageWithReaction; - } - - _addOwnReactionToMessage( - ownReactions: ReactionResponse[] | null | undefined, - reaction: ReactionResponse, - enforce_unique?: boolean, - ) { - if (enforce_unique) { - ownReactions = []; - } else { - ownReactions = this._removeOwnReactionFromMessage(ownReactions, reaction); - } - - ownReactions = ownReactions || []; - if (this._channel.getClient().userID === reaction.user_id) { - ownReactions.push(reaction); - } - - return ownReactions; - } - - _removeOwnReactionFromMessage( - ownReactions: ReactionResponse[] | null | undefined, - reaction: ReactionResponse, - ) { - if (ownReactions) { - return ownReactions.filter( - (item) => item.user_id !== reaction.user_id || item.type !== reaction.type, - ); - } - return ownReactions; - } - - /** - * removeReaction - keeps the pinned-message copy's reactions in sync (see `addReaction`). - */ - removeReaction(reaction: ReactionResponse, message?: MessageResponse) { - if (!message) { - return; - } - - const messageWithRemovedReaction = message; - const updateData = { - id: messageWithRemovedReaction.id, - parent_id: messageWithRemovedReaction.parent_id, - pinned: messageWithRemovedReaction.pinned, - show_in_channel: messageWithRemovedReaction.show_in_channel, - }; - this._updateMessage(updateData, (msg) => { - messageWithRemovedReaction.own_reactions = this._removeOwnReactionFromMessage( - msg.own_reactions, - reaction, - ); - return this.formatMessage(messageWithRemovedReaction); - }); - return messageWithRemovedReaction; - } - - /** - * Updates the pinned-message copy of the given message. The channel message list is owned by - * `channel.messagePaginator` and thread replies by the `Thread` object. - * @param message - * @param updateFunc - */ - _updateMessage( - message: { - id?: string; - pinned?: boolean; - }, - updateFunc: (msg: LocalMessage) => LocalMessage, - ) { - const { pinned } = message; - - if (pinned) { - const msgIndex = this.pinnedMessages.findIndex((msg) => msg.id === message.id); - if (msgIndex !== -1) { - this.pinnedMessages[msgIndex] = updateFunc(this.pinnedMessages[msgIndex]); - } - } - } - /** * Setter for isUpToDate. * @@ -529,81 +356,6 @@ export class ChannelState { this.isUpToDate = isUpToDate; }; - /** - * _addToMessageList - Adds a message to a list of messages, tries to update first, appends if message isn't found - * - * @param {Array} messages A list of messages - * @param message - * @param {boolean} timestampChanged Whether updating a message with changed created_at value. - * @param {string} sortBy field name to use to sort the messages by - * @param {boolean} addIfDoesNotExist Add message if it is not in the list, used to prevent out of order updated messages from being added. - */ - _addToMessageList( - messages: Array, - message: LocalMessage, - timestampChanged = false, - sortBy: 'pinned_at' | 'created_at' = 'created_at', - addIfDoesNotExist = true, - ) { - return addToMessageList( - messages, - message, - timestampChanged, - sortBy, - addIfDoesNotExist, - ); - } - - removeMessageFromArray = ( - msgArray: Array, - msg: { id: string; parent_id?: string }, - ) => { - const result = msgArray.filter( - (message) => !(!!message.id && !!msg.id && message.id === msg.id), - ); - - return { removed: result.length < msgArray.length, result }; - }; - - /** - * Updates the message.user property with updated user object, for messages. - * - * @param {UserResponse} user - */ - updateUserMessages = (user: UserResponse) => { - // The channel message list updates user references on the paginator - // (messagePaginator.reflectUserUpdate) and thread replies via the Thread object; this keeps the - // pinned-message cache in sync. - for (let i = 0; i < this.pinnedMessages.length; i++) { - const m = this.pinnedMessages[i]; - if (m.user?.id === user.id) { - this.pinnedMessages[i] = { ...m, user }; - } - } - }; - - /** - * Marks the messages as deleted, from deleted user. - * - * @param {UserResponse} user - * @param {boolean} hardDelete - */ - deleteUserMessages = ( - user: UserResponse, - hardDelete = false, - deletedAt?: LocalMessage['deleted_at'], - ) => { - // The channel message list applies deletions on the paginator - // (messagePaginator.applyMessageDeletionForUser) and thread replies via the Thread object; this - // keeps the pinned-message cache in sync. - _deleteUserMessages({ - messages: this.pinnedMessages, - user, - hardDelete, - deletedAt: deletedAt ?? null, - }); - }; - /** * clean - Remove stale data such as users that stayed in typing state for more than 5 seconds */ @@ -625,12 +377,4 @@ export class ChannelState { } } } - - /** - * Clears the pinned-message cache. The main channel message list is owned by the paginator - * (clear it via `channel.messagePaginator.clearStateAndCache()`). - */ - clearMessages() { - this.pinnedMessages = []; - } } diff --git a/src/client.ts b/src/client.ts index 2b327f063..cb8ddd2b4 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1480,13 +1480,8 @@ export class StreamChat { if (!channel) continue; - const state = channel.state; - /** update the messages from this user. */ - // Legacy dual-write retained until the ChannelState message-set removal (Task 10). The - // paginator update keeps messagePaginator-backed readers (e.g. lastMessage) in sync; thread - // replies live in thread paginators and are handled by the thread migration. - state?.updateUserMessages(user); + channel.pinnedMessagesPaginator.reflectUserUpdate(user); channel.messagePaginator.reflectUserUpdate(user); } }; @@ -1512,8 +1507,6 @@ export class StreamChat { for (const channelID in refMap) { const channel = this.activeChannels[channelID]; if (channel) { - const state = channel.state; - /** deleted the messages from this user. */ channel.messagePaginator.applyMessageDeletionForUser({ userId: user.id, @@ -1525,7 +1518,6 @@ export class StreamChat { hardDelete, deletedAt: deletedAt ?? new Date(), }); - state?.deleteUserMessages(user, hardDelete, deletedAt); } } }; @@ -2328,8 +2320,7 @@ export class StreamChat { if (skipInitialization === undefined) { c._initializeState(channelState); } else if (!skipInitialization.includes(channelState.channel.id)) { - // clearMessages() resets the pinned cache; the paginator is (re)seeded above. - c.state.clearMessages(); + // The paginators are (re)seeded above and via _initializeState → seedFirstPageSync. c._initializeState(channelState); } diff --git a/src/utils.ts b/src/utils.ts index 0289516f9..d50551d9f 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -479,39 +479,6 @@ export const toDeletedMessage = ({ } }; -export const deleteUserMessages = ({ - messages, - user, - hardDelete = false, - deletedAt, -}: { - messages: Array; - user: UserResponse; - hardDelete: boolean; - deletedAt: LocalMessage['deleted_at']; -}) => { - for (let i = 0; i < messages.length; i++) { - const message = messages[i]; - if (message.user?.id === user.id) { - messages[i] = - message.type === 'deleted' - ? message - : (toDeletedMessage({ message, hardDelete, deletedAt }) as LocalMessage); - } - - if (messages[i].quoted_message && message.quoted_message?.user?.id === user.id) { - messages[i].quoted_message = - message.quoted_message.type === 'deleted' - ? message.quoted_message - : (toDeletedMessage({ - message: messages[i].quoted_message as LocalMessageBase, - hardDelete, - deletedAt, - }) as LocalMessage); - } - } -}; - export const findIndexInSortedArray = ({ needle, sortedArray, @@ -601,72 +568,6 @@ export const findIndexInSortedArray = ({ return left; }; -export function addToMessageList( - messages: readonly T[], - newMessage: T, - timestampChanged = false, - sortBy: 'pinned_at' | 'created_at' = 'created_at', - addIfDoesNotExist = true, -) { - const addMessageToList = addIfDoesNotExist || timestampChanged; - let newMessages = [...messages]; - - // if created_at has changed, message should be filtered and re-inserted in correct order - // slow op but usually this only happens for a message inserted to state before actual response with correct timestamp - if (timestampChanged) { - newMessages = newMessages.filter( - (message) => !(message.id && newMessage.id === message.id), - ); - } - - // for empty list just concat and return unless it's an update or deletion - if (newMessages.length === 0 && addMessageToList) { - return newMessages.concat(newMessage); - } else if (newMessages.length === 0) { - return newMessages; - } - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const messageTime = newMessage[sortBy]!.getTime(); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const messageIsNewest = newMessages.at(-1)![sortBy]!.getTime() < messageTime; - - // if message is newer than last item in the list concat and return unless it's an update or deletion - if (messageIsNewest && addMessageToList) { - return newMessages.concat(newMessage); - } else if (messageIsNewest) { - return newMessages; - } - - // find the closest index to push the new message - const insertionIndex = findIndexInSortedArray({ - needle: newMessage, - sortedArray: newMessages, - sortDirection: 'ascending', - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - selectValueToCompare: (m) => m[sortBy]!.getTime(), - selectKey: (m) => m.id, - }); - - // message already exists and not filtered with timestampChanged, update and return - if ( - !timestampChanged && - newMessage.id && - newMessages[insertionIndex] && - newMessage.id === newMessages[insertionIndex].id - ) { - newMessages[insertionIndex] = newMessage; - return newMessages; - } - - // do not add updated or deleted messages to the list if they already exist or come with a timestamp change - if (addMessageToList) { - newMessages.splice(insertionIndex, 0, newMessage); - } - - return newMessages; -} - function maybeGetReactionGroupsFallback( groups: { [key: string]: ReactionGroupResponse } | null | undefined, counts: { [key: string]: number } | null | undefined, diff --git a/test/unit/channel.test.js b/test/unit/channel.test.js index 2a8b9d316..cdb94f3ef 100644 --- a/test/unit/channel.test.js +++ b/test/unit/channel.test.js @@ -435,637 +435,765 @@ describe('Channel _handleChannelEvent', function () { channel.initialized = true; }); - it('feeds the pinnedMessagesPaginator on pin and unpin events', () => { - const existing = generateMsg({ - id: 'pinned-existing', + const makePinned = (id, dateISO, overrides = {}) => + generateMsg({ + id, cid: channel.cid, pinned: true, - pinned_at: new Date('2020-01-01T00:00:00.001Z').toISOString(), + pinned_at: dateISO, + date: dateISO, + ...overrides, }); + + const seedPinned = (messages) => channel.pinnedMessagesPaginator.ingestPage({ - page: [formatMessage(existing)], + page: messages.map(formatMessage), isHead: true, isTail: true, setActive: true, }); - expect(channel.pinnedMessagesPaginator.items?.map((m) => m.id)).to.eql([ - 'pinned-existing', - ]); - // A newly pinned message arrives → auto-added. - const newlyPinned = generateMsg({ - id: 'pinned-new', - cid: channel.cid, - pinned: true, - pinned_at: new Date('2020-01-01T00:00:00.002Z').toISOString(), - }); - channel._handleChannelEvent({ type: 'message.new', message: newlyPinned, user }); - expect(channel.pinnedMessagesPaginator.items?.map((m) => m.id)).to.include( - 'pinned-new', - ); + const pinnedIds = () => channel.pinnedMessagesPaginator.items?.map((m) => m.id) ?? []; - // The existing message is unpinned via message.updated → auto-removed. - channel._handleChannelEvent({ - type: 'message.updated', - message: { ...existing, pinned: false, pinned_at: null }, - }); - expect(channel.pinnedMessagesPaginator.items?.map((m) => m.id)).to.not.include( - 'pinned-existing', - ); - }); + describe('member.added / member.updated / member.removed', () => { + it('member.updated/member.added are being handled properly (ChannelState.membership & ChannelState.members)', () => { + expect(channel.state.members).to.be.empty; + expect(channel.state.membership).to.be.empty; - it('member.updated/member.added are being handled properly (ChannelState.membership & ChannelState.members)', () => { - expect(channel.state.members).to.be.empty; - expect(channel.state.membership).to.be.empty; + const currentMember = generateMember({ + user, + pinned_at: new Date().toISOString(), + archived_at: new Date().toISOString(), + }); - const currentMember = generateMember({ - user, - pinned_at: new Date().toISOString(), - archived_at: new Date().toISOString(), - }); + const otherMember = generateMember({ + user: { id: 'user-other' }, + }); - const otherMember = generateMember({ - user: { id: 'user-other' }, - }); + channel._handleChannelEvent({ + type: 'member.added', + user, + member: currentMember, + }); - channel._handleChannelEvent({ - type: 'member.added', - user, - member: currentMember, - }); + expect(channel.state.members).to.have.property(user.id); + expect(channel.state.members[user.id]).to.deep.equal(currentMember); + expect(channel.state.membership).to.deep.equal(currentMember); - expect(channel.state.members).to.have.property(user.id); - expect(channel.state.members[user.id]).to.deep.equal(currentMember); - expect(channel.state.membership).to.deep.equal(currentMember); + channel._handleChannelEvent({ + type: 'member.added', + user, + member: otherMember, + }); - channel._handleChannelEvent({ - type: 'member.added', - user, - member: otherMember, - }); + expect(channel.state.members).to.have.keys([user.id, otherMember.user.id]); + expect(channel.state.members[otherMember.user.id]).to.deep.equal(otherMember); + expect(channel.state.members[user.id]).to.deep.equal(currentMember); + expect(channel.state.membership).to.deep.equal(currentMember); - expect(channel.state.members).to.have.keys([user.id, otherMember.user.id]); - expect(channel.state.members[otherMember.user.id]).to.deep.equal(otherMember); - expect(channel.state.members[user.id]).to.deep.equal(currentMember); - expect(channel.state.membership).to.deep.equal(currentMember); + const currentMemberUpdated = generateMember({ + user, + pinned_at: null, + archived_at: null, + }); - const currentMemberUpdated = generateMember({ - user, - pinned_at: null, - archived_at: null, - }); + channel._handleChannelEvent({ + type: 'member.updated', + user, + member: currentMemberUpdated, + }); - channel._handleChannelEvent({ - type: 'member.updated', - user, - member: currentMemberUpdated, + expect(channel.state.membership).to.not.have.keys(['pinned_at', 'archived_at']); + expect(channel.state.membership).to.equal(channel.state.members[user.id]); }); - expect(channel.state.membership).to.not.have.keys(['pinned_at', 'archived_at']); - expect(channel.state.membership).to.equal(channel.state.members[user.id]); - }); + it('does not change channel.data.member_count on member.added or member.removed', () => { + channel.data = { member_count: 5 }; - it('does not change channel.data.member_count on member.added or member.removed', () => { - channel.data = { member_count: 5 }; + const newMember = generateMember({ user: { id: 'user-new' } }); - const newMember = generateMember({ user: { id: 'user-new' } }); + channel._handleChannelEvent({ + type: 'member.added', + user: newMember.user, + member: newMember, + }); - channel._handleChannelEvent({ - type: 'member.added', - user: newMember.user, - member: newMember, - }); + expect(channel.data.member_count).to.equal(5); - expect(channel.data.member_count).to.equal(5); + channel._handleChannelEvent({ + type: 'member.removed', + user: newMember.user, + member: newMember, + }); - channel._handleChannelEvent({ - type: 'member.removed', - user: newMember.user, - member: newMember, + expect(channel.data.member_count).to.equal(5); }); - - expect(channel.data.member_count).to.equal(5); }); - it('message.new does not reset the unreadCount for current user messages', function () { - channel.state.unreadCount = 100; - channel._handleChannelEvent({ - type: 'message.new', - user, - message: generateMsg(), - }); + describe('message.new', () => { + it('message.new does not reset the unreadCount for current user messages', function () { + channel.state.unreadCount = 100; + channel._handleChannelEvent({ + type: 'message.new', + user, + message: generateMsg(), + }); - expect(channel.state.unreadCount).to.be.equal(100); - }); + expect(channel.state.unreadCount).to.be.equal(100); + }); - it('message.new does not reset the unreadCount for own thread replies', function () { - channel.state.unreadCount = 100; - channel._handleChannelEvent({ - type: 'message.new', - user, - message: generateMsg({ - parent_id: 'parentId', - type: 'reply', + it('message.new does not reset the unreadCount for own thread replies', function () { + channel.state.unreadCount = 100; + channel._handleChannelEvent({ + type: 'message.new', user, - }), - }); + message: generateMsg({ + parent_id: 'parentId', + type: 'reply', + user, + }), + }); - expect(channel.state.unreadCount).to.be.equal(100); - }); + expect(channel.state.unreadCount).to.be.equal(100); + }); - it('message.new does not reset the unreadCount for others thread replies', function () { - channel.state.unreadCount = 100; - channel._handleChannelEvent({ - type: 'message.new', - user: { id: 'id' }, - message: generateMsg({ - parent_id: 'parentId', - type: 'reply', + it('message.new does not reset the unreadCount for others thread replies', function () { + channel.state.unreadCount = 100; + channel._handleChannelEvent({ + type: 'message.new', user: { id: 'id' }, - }), + message: generateMsg({ + parent_id: 'parentId', + type: 'reply', + user: { id: 'id' }, + }), + }); + + expect(channel.state.unreadCount).to.be.equal(100); }); - expect(channel.state.unreadCount).to.be.equal(100); - }); + it('message.new ingests message into messagePaginator even for own messages', function () { + const message = generateMsg({ id: 'own-message-id', user }); - it('message.new ingests message into messagePaginator even for own messages', function () { - const message = generateMsg({ id: 'own-message-id', user }); + channel._handleChannelEvent({ + type: 'message.new', + user, + message, + }); - channel._handleChannelEvent({ - type: 'message.new', - user, - message, + expect(channel.messagePaginator.getItem(message.id)?.id).to.equal(message.id); }); - expect(channel.messagePaginator.getItem(message.id)?.id).to.equal(message.id); - }); + it('message.new ignores thread replies in messagePaginator', function () { + const message = generateMsg({ + id: 'thread-reply-message-id', + parent_id: 'parent-message-id', + user: { id: 'another-user' }, + }); - it('message.new ignores thread replies in messagePaginator', function () { - const message = generateMsg({ - id: 'thread-reply-message-id', - parent_id: 'parent-message-id', - user: { id: 'another-user' }, - }); + channel._handleChannelEvent({ + type: 'message.new', + user: message.user, + message, + }); - channel._handleChannelEvent({ - type: 'message.new', - user: message.user, - message, + expect(channel.messagePaginator.getItem(message.id)).to.be.undefined; }); - expect(channel.messagePaginator.getItem(message.id)).to.be.undefined; - }); - - it('message.new increment unreadCount properly', function () { - channel.state.unreadCount = 20; - channel._handleChannelEvent({ - type: 'message.new', - user: { id: 'id' }, - message: generateMsg({ user: { id: 'id' } }), - }); - expect(channel.state.unreadCount).to.be.equal(21); - channel._handleChannelEvent({ - type: 'message.new', - user: { id: 'id2' }, - message: generateMsg({ user: { id: 'id2' } }), + it('message.new increment unreadCount properly', function () { + channel.state.unreadCount = 20; + channel._handleChannelEvent({ + type: 'message.new', + user: { id: 'id' }, + message: generateMsg({ user: { id: 'id' } }), + }); + expect(channel.state.unreadCount).to.be.equal(21); + channel._handleChannelEvent({ + type: 'message.new', + user: { id: 'id2' }, + message: generateMsg({ user: { id: 'id2' } }), + }); + expect(channel.state.unreadCount).to.be.equal(22); }); - expect(channel.state.unreadCount).to.be.equal(22); - }); - it('message.new skip increment for silent/shadowed/muted messages', function () { - channel.state.unreadCount = 30; - channel._handleChannelEvent({ - type: 'message.new', - user: { id: 'id' }, - message: generateMsg({ silent: true }), + it('message.new skip increment for silent/shadowed/muted messages', function () { + channel.state.unreadCount = 30; + channel._handleChannelEvent({ + type: 'message.new', + user: { id: 'id' }, + message: generateMsg({ silent: true }), + }); + expect(channel.state.unreadCount).to.be.equal(30); + channel._handleChannelEvent({ + type: 'message.new', + user: { id: 'id2' }, + message: generateMsg({ shadowed: true }), + }); + expect(channel.state.unreadCount).to.be.equal(30); + channel._handleChannelEvent({ + type: 'message.new', + user: { id: 'mute1' }, + message: generateMsg({ user: { id: 'mute1' } }), + }); + expect(channel.state.unreadCount).to.be.equal(30); }); - expect(channel.state.unreadCount).to.be.equal(30); - channel._handleChannelEvent({ - type: 'message.new', - user: { id: 'id2' }, - message: generateMsg({ shadowed: true }), + + it('should include unread_messages for message events from another user', () => { + channel.state.read['id'] = { + unread_messages: 2, + }; + + const message = generateMsg(); + + const events = [ + 'message.read', + 'message.deleted', + 'message.new', + 'message.updated', + 'member.added', + 'member.updated', + 'member.removed', + ]; + + for (const event of events) { + channel.state.read['id'].unread_messages = 2; + channel._handleChannelEvent({ + type: event, + user: { id: 'id' }, + message, + }); + expect( + channel.state.read['id'].unread_messages, + `${event} should not be undefined`, + ).not.to.be.undefined; + } }); - expect(channel.state.unreadCount).to.be.equal(30); - channel._handleChannelEvent({ - type: 'message.new', - user: { id: 'mute1' }, - message: generateMsg({ user: { id: 'mute1' } }), + + it('should include unread_messages for message events from the current user', () => { + channel.state.read[client.user.id] = { + unread_messages: 2, + }; + + const message = generateMsg({ user: { id: client.userID } }); + + const events = [ + 'message.read', + 'message.deleted', + 'message.new', + 'message.updated', + 'member.added', + 'member.updated', + 'member.removed', + ]; + + for (const event of events) { + channel.state.read['id'] = { + unread_messages: 2, + }; + + channel._handleChannelEvent({ + type: event, + user: { id: client.user.id }, + message, + }); + expect( + channel.state.read[client.user.id].unread_messages, + `${event} should not be undefined`, + ).not.to.be.undefined; + } + }); + + // Also covers the message.updated unpin path: a pinned message unpinned via + // message.updated is removed from the pinnedMessagesPaginator. + it('feeds the pinnedMessagesPaginator on pin and unpin events', () => { + const existing = generateMsg({ + id: 'pinned-existing', + cid: channel.cid, + pinned: true, + pinned_at: new Date('2020-01-01T00:00:00.001Z').toISOString(), + }); + channel.pinnedMessagesPaginator.ingestPage({ + page: [formatMessage(existing)], + isHead: true, + isTail: true, + setActive: true, + }); + expect(channel.pinnedMessagesPaginator.items?.map((m) => m.id)).to.eql([ + 'pinned-existing', + ]); + + // A newly pinned message arrives → auto-added. + const newlyPinned = generateMsg({ + id: 'pinned-new', + cid: channel.cid, + pinned: true, + pinned_at: new Date('2020-01-01T00:00:00.002Z').toISOString(), + }); + channel._handleChannelEvent({ type: 'message.new', message: newlyPinned, user }); + expect(channel.pinnedMessagesPaginator.items?.map((m) => m.id)).to.include( + 'pinned-new', + ); + + // The existing message is unpinned via message.updated → auto-removed. + channel._handleChannelEvent({ + type: 'message.updated', + message: { ...existing, pinned: false, pinned_at: null }, + }); + expect(channel.pinnedMessagesPaginator.items?.map((m) => m.id)).to.not.include( + 'pinned-existing', + ); }); - expect(channel.state.unreadCount).to.be.equal(30); }); - it('message.updated syncs reply metadata into messagePaginator', function () { - const parentMessage = generateMsg({ - id: 'parent-message-id', - reply_count: 1, - thread_participants: [{ id: 'user-1' }], - }); + describe('message.updated', () => { + it('message.updated syncs reply metadata into messagePaginator', function () { + const parentMessage = generateMsg({ + id: 'parent-message-id', + reply_count: 1, + thread_participants: [{ id: 'user-1' }], + }); - channel.messagePaginator.ingestItem(parentMessage); + channel.messagePaginator.ingestItem(parentMessage); - channel._handleChannelEvent({ - type: 'message.updated', - message: { - ...parentMessage, - reply_count: 29, - thread_participants: [{ id: 'user-1' }, { id: 'user-2' }], - }, + channel._handleChannelEvent({ + type: 'message.updated', + message: { + ...parentMessage, + reply_count: 29, + thread_participants: [{ id: 'user-1' }, { id: 'user-2' }], + }, + }); + + const parentFromPaginator = channel.messagePaginator.getItem(parentMessage.id); + expect(parentFromPaginator?.reply_count).to.be.equal(29); + expect(parentFromPaginator?.thread_participants).to.have.length(2); }); - const parentFromPaginator = channel.messagePaginator.getItem(parentMessage.id); - expect(parentFromPaginator?.reply_count).to.be.equal(29); - expect(parentFromPaginator?.thread_participants).to.have.length(2); - }); + it('message.updated ignores thread replies in messagePaginator', function () { + const parentMessage = generateMsg({ id: 'thread-parent-id' }); + const threadReply = generateMsg({ + id: 'thread-reply-id', + parent_id: parentMessage.id, + text: 'before update', + }); - it('message.updated ignores thread replies in messagePaginator', function () { - const parentMessage = generateMsg({ id: 'thread-parent-id' }); - const threadReply = generateMsg({ - id: 'thread-reply-id', - parent_id: parentMessage.id, - text: 'before update', - }); + channel.messagePaginator.ingestItem(parentMessage); + channel._handleChannelEvent({ + type: 'message.updated', + message: { ...threadReply, text: 'after update' }, + }); - channel.messagePaginator.ingestItem(parentMessage); - channel._handleChannelEvent({ - type: 'message.updated', - message: { ...threadReply, text: 'after update' }, + expect(channel.messagePaginator.getItem(threadReply.id)).to.be.undefined; }); - expect(channel.messagePaginator.getItem(threadReply.id)).to.be.undefined; - }); + it('message.updated syncs quoted_message references in messagePaginator', function () { + const quotedMessage = generateMsg({ + id: 'quoted-message-id', + text: 'before update', + }); + const quoteCarrier = generateMsg({ + id: 'quote-carrier-id', + quoted_message_id: quotedMessage.id, + quoted_message: quotedMessage, + }); - it('message.updated syncs quoted_message references in messagePaginator', function () { - const quotedMessage = generateMsg({ - id: 'quoted-message-id', - text: 'before update', - }); - const quoteCarrier = generateMsg({ - id: 'quote-carrier-id', - quoted_message_id: quotedMessage.id, - quoted_message: quotedMessage, - }); + channel.messagePaginator.setItems({ + valueOrFactory: [quotedMessage, quoteCarrier], + isFirstPage: true, + isLastPage: true, + }); - channel.messagePaginator.setItems({ - valueOrFactory: [quotedMessage, quoteCarrier], - isFirstPage: true, - isLastPage: true, - }); + channel._handleChannelEvent({ + type: 'message.updated', + message: { + ...quotedMessage, + text: 'after update', + }, + }); - channel._handleChannelEvent({ - type: 'message.updated', - message: { - ...quotedMessage, - text: 'after update', - }, + expect( + channel.messagePaginator.getItem(quoteCarrier.id)?.quoted_message?.text, + ).to.equal('after update'); }); - expect( - channel.messagePaginator.getItem(quoteCarrier.id)?.quoted_message?.text, - ).to.equal('after update'); - }); + // Also covers message.deleted (both event payloads are enriched with own_reactions). + it('should extend "message.updated" and "message.deleted" event payloads with "own_reactions"', () => { + const own_reactions = [ + { + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + type: 'wow', + }, + ]; + // Thread-reply own_reactions preservation is owned by the Thread object (covered in + // threads.test.ts); at the channel level only the paginator-backed message list is enriched. + const message = generateMsg({ own_reactions }); + seedLatestWindow(channel, [message]); + + ['message.updated', 'message.deleted'].forEach((eventType) => { + let receivedEvent; + channel.on(eventType, (e) => (receivedEvent = e)); + + const event = { + type: eventType, + // own_reactions is always [] in WS events + message: { ...message, own_reactions: [] }, + }; + channel._handleChannelEvent(event); + channel._callChannelListeners(event); - it('message.undeleted ignores thread replies in messagePaginator', function () { - const parentMessage = generateMsg({ id: 'thread-parent-id-2' }); - const threadReply = generateMsg({ - id: 'thread-reply-id-2', - parent_id: parentMessage.id, - text: 'undeleted reply', + const stored = channel.messagePaginator.getItem(message.id); + expect(stored.own_reactions.length).to.equal(own_reactions.length); + expect(receivedEvent.message.own_reactions.length).to.equal(own_reactions.length); + }); }); - channel.messagePaginator.ingestItem(parentMessage); - channel._handleChannelEvent({ - type: 'message.undeleted', - message: threadReply, + // Also covers message.deleted (quoted_message references update on both events). + it('should update quoted_message references on "message.updated" and "message.deleted" event', () => { + // Thread-reply quoted-message updates are owned by the Thread object (Thread.messagePaginator + // .reflectQuotedMessageUpdate); this exercises the channel's paginator-backed message list. + const originalText = 'XX'; + const updatedText = 'YY'; + const quoted_message = generateMsg({ + date: new Date(2).toISOString(), + id: 'quoted-message', + text: originalText, + }); + const quotingMessage = generateMsg({ + date: new Date(3).toISOString(), + id: 'quoting-message', + quoted_message, + quoted_message_id: quoted_message.id, + }); + const updatedQuotedMessage = { ...quoted_message, text: updatedText }; + ['message.updated', 'message.deleted'].forEach((eventType) => { + seedLatestWindow(channel, [quoted_message, quotingMessage]); + const event = { type: eventType, message: updatedQuotedMessage }; + channel._handleChannelEvent(event); + const stored = channel.messagePaginator.getItem(quotingMessage.id); + expect(stored.quoted_message.text).to.equal(updatedQuotedMessage.text); + channel.messagePaginator.clearStateAndCache(); + }); }); - - expect(channel.messagePaginator.getItem(threadReply.id)).to.be.undefined; }); - it('message.undeleted syncs quoted_message references in messagePaginator', function () { - const quotedMessage = generateMsg({ - id: 'quoted-message-id-undeleted', - type: 'deleted', - text: 'before undelete', - }); - const quoteCarrier = generateMsg({ - id: 'quote-carrier-id-undeleted', - quoted_message_id: quotedMessage.id, - quoted_message: quotedMessage, - }); + describe('message.undeleted', () => { + it('message.undeleted ignores thread replies in messagePaginator', function () { + const parentMessage = generateMsg({ id: 'thread-parent-id-2' }); + const threadReply = generateMsg({ + id: 'thread-reply-id-2', + parent_id: parentMessage.id, + text: 'undeleted reply', + }); - channel.messagePaginator.setItems({ - valueOrFactory: [quotedMessage, quoteCarrier], - isFirstPage: true, - isLastPage: true, - }); + channel.messagePaginator.ingestItem(parentMessage); + channel._handleChannelEvent({ + type: 'message.undeleted', + message: threadReply, + }); - channel._handleChannelEvent({ - type: 'message.undeleted', - message: { - ...quotedMessage, - type: 'regular', - text: 'after undelete', - }, + expect(channel.messagePaginator.getItem(threadReply.id)).to.be.undefined; }); - expect( - channel.messagePaginator.getItem(quoteCarrier.id)?.quoted_message?.text, - ).to.equal('after undelete'); - expect( - channel.messagePaginator.getItem(quoteCarrier.id)?.quoted_message?.type, - ).to.equal('regular'); - }); - - it('does not override the delivery information in the read status', () => {}); + it('message.undeleted syncs quoted_message references in messagePaginator', function () { + const quotedMessage = generateMsg({ + id: 'quoted-message-id-undeleted', + type: 'deleted', + text: 'before undelete', + }); + const quoteCarrier = generateMsg({ + id: 'quote-carrier-id-undeleted', + quoted_message_id: quotedMessage.id, + quoted_message: quotedMessage, + }); - it('message.truncate removes all messages if "truncated_at" is "now"', function () { - const messages = [ - { created_at: '2021-01-01T00:01:00' }, - { created_at: '2021-01-01T00:02:00' }, - { created_at: '2021-01-01T00:03:00' }, - ].map(generateMsg); + channel.messagePaginator.setItems({ + valueOrFactory: [quotedMessage, quoteCarrier], + isFirstPage: true, + isLastPage: true, + }); - seedLatestWindow(channel, messages); - expect(channel.messagePaginator.latestItems.length).to.be.equal(3); + channel._handleChannelEvent({ + type: 'message.undeleted', + message: { + ...quotedMessage, + type: 'regular', + text: 'after undelete', + }, + }); - channel._handleChannelEvent({ - type: 'channel.truncated', - user: { id: 'id' }, - channel: { - truncated_at: new Date().toISOString(), - }, + expect( + channel.messagePaginator.getItem(quoteCarrier.id)?.quoted_message?.text, + ).to.equal('after undelete'); + expect( + channel.messagePaginator.getItem(quoteCarrier.id)?.quoted_message?.type, + ).to.equal('regular'); }); - - expect(channel.messagePaginator.latestItems.length).to.be.equal(0); }); - it('message.truncate clears messagePaginator unread snapshot', function () { - const cachedMessage = generateMsg({ - date: '2020-01-01T00:00:00.000Z', - id: 'truncate-cached-message-id', - }); - channel.messagePaginator.setItems({ - valueOrFactory: [cachedMessage], - isFirstPage: true, - isLastPage: true, - }); - channel.messagePaginator.setUnreadSnapshot({ - firstUnreadMessageId: 'm-1', - lastReadAt: new Date('2021-01-01T00:00:00.000Z'), - lastReadMessageId: 'm-0', - unreadCount: 7, - }); + describe('channel.truncated', () => { + it('message.truncate removes all messages if "truncated_at" is "now"', function () { + const messages = [ + { created_at: '2021-01-01T00:01:00' }, + { created_at: '2021-01-01T00:02:00' }, + { created_at: '2021-01-01T00:03:00' }, + ].map(generateMsg); - channel._handleChannelEvent({ - type: 'channel.truncated', - user: { id: 'id' }, - channel: { - truncated_at: new Date().toISOString(), - }, - }); + seedLatestWindow(channel, messages); + expect(channel.messagePaginator.latestItems.length).to.be.equal(3); - expect(channel.messagePaginator.unreadStateSnapshot.getLatestValue()).toEqual({ - firstUnreadMessageId: null, - lastReadAt: null, - lastReadMessageId: null, - unreadCount: 0, + channel._handleChannelEvent({ + type: 'channel.truncated', + user: { id: 'id' }, + channel: { + truncated_at: new Date().toISOString(), + }, + }); + + expect(channel.messagePaginator.latestItems.length).to.be.equal(0); }); - // Partial truncate (truncated_at in the past) prunes the older-than-cutoff message; the - // emptied active window resolves to an empty item list. - expect(channel.messagePaginator.items ?? []).toEqual([]); - expect(channel.messagePaginator.getItem(cachedMessage.id)).toBeUndefined(); - }); - it('message.truncate removes messages up to specified date', function () { - const messages = [ - { created_at: '2021-01-01T00:01:00' }, - { created_at: '2021-01-01T00:02:00' }, - { created_at: '2021-01-01T00:03:00' }, - ].map(generateMsg); + it('message.truncate clears messagePaginator unread snapshot', function () { + const cachedMessage = generateMsg({ + date: '2020-01-01T00:00:00.000Z', + id: 'truncate-cached-message-id', + }); + channel.messagePaginator.setItems({ + valueOrFactory: [cachedMessage], + isFirstPage: true, + isLastPage: true, + }); + channel.messagePaginator.setUnreadSnapshot({ + firstUnreadMessageId: 'm-1', + lastReadAt: new Date('2021-01-01T00:00:00.000Z'), + lastReadMessageId: 'm-0', + unreadCount: 7, + }); - seedLatestWindow(channel, messages); - expect(channel.messagePaginator.latestItems.length).to.be.equal(3); + channel._handleChannelEvent({ + type: 'channel.truncated', + user: { id: 'id' }, + channel: { + truncated_at: new Date().toISOString(), + }, + }); - channel._handleChannelEvent({ - type: 'channel.truncated', - user: { id: 'id' }, - channel: { - truncated_at: messages[1].created_at, - }, + expect(channel.messagePaginator.unreadStateSnapshot.getLatestValue()).toEqual({ + firstUnreadMessageId: null, + lastReadAt: null, + lastReadMessageId: null, + unreadCount: 0, + }); + // Partial truncate (truncated_at in the past) prunes the older-than-cutoff message; the + // emptied active window resolves to an empty item list. + expect(channel.messagePaginator.items ?? []).toEqual([]); + expect(channel.messagePaginator.getItem(cachedMessage.id)).toBeUndefined(); }); - expect(channel.messagePaginator.latestItems.length).to.be.equal(2); - }); + it('message.truncate removes messages up to specified date', function () { + const messages = [ + { created_at: '2021-01-01T00:01:00' }, + { created_at: '2021-01-01T00:02:00' }, + { created_at: '2021-01-01T00:03:00' }, + ].map(generateMsg); - it('message.truncate removes pinned messages up to specified date', function () { - const messages = [ - { - created_at: '2021-01-01T00:01:00', - pinned: true, - pinned_at: new Date('2021-01-01T00:01:01.010Z'), - }, - { created_at: '2021-01-01T00:02:00' }, - { - created_at: '2021-01-01T00:03:00', - pinned: true, - pinned_at: new Date('2021-01-01T00:02:02.011Z'), - }, - ].map(generateMsg); + seedLatestWindow(channel, messages); + expect(channel.messagePaginator.latestItems.length).to.be.equal(3); - seedLatestWindow(channel, messages); - channel.state.addPinnedMessages(messages.filter((m) => m.pinned)); - expect(channel.messagePaginator.latestItems.length).to.be.equal(3); - expect(channel.state.pinnedMessages.length).to.be.equal(2); + channel._handleChannelEvent({ + type: 'channel.truncated', + user: { id: 'id' }, + channel: { + truncated_at: messages[1].created_at, + }, + }); - channel._handleChannelEvent({ - type: 'channel.truncated', - user: { id: 'id' }, - channel: { - truncated_at: messages[1].created_at, - }, + expect(channel.messagePaginator.latestItems.length).to.be.equal(2); }); - expect(channel.messagePaginator.latestItems.length).to.be.equal(2); - expect(channel.state.pinnedMessages.length).to.be.equal(1); - }); + it('prunes pinned messages older than the cutoff on a partial channel.truncated', () => { + seedPinned([ + makePinned('old', '2020-01-01T00:00:00.000Z'), + makePinned('new', '2020-03-01T00:00:00.000Z'), + ]); + expect(pinnedIds()).to.eql(['old', 'new']); - it('message.delete removes quoted messages references', function () { - const originalMessage = generateMsg({ silent: true }); - channel._handleChannelEvent({ - type: 'message.new', - user: { id: 'id' }, - message: originalMessage, - }); + channel._handleChannelEvent({ + type: 'channel.truncated', + channel: { truncated_at: '2020-02-01T00:00:00.000Z' }, + }); - const quotingMessage = generateMsg({ - silent: true, - quoted_message: originalMessage, - quoted_message_id: originalMessage.id, + expect(pinnedIds()).to.eql(['new']); }); - channel._handleChannelEvent({ - type: 'message.new', - user: { id: 'id2' }, - message: quotingMessage, - }); + it('clears pinned messages on a full channel.truncated', () => { + seedPinned([makePinned('p', '2020-01-01T00:00:00.000Z')]); - channel._handleChannelEvent({ - type: 'message.deleted', - user: { id: 'id' }, - message: { ...originalMessage, deleted_at: new Date().toISOString() }, - }); + channel._handleChannelEvent({ type: 'channel.truncated', channel: {} }); - expect(channel.messagePaginator.getItem(quotingMessage.id).quoted_message.deleted_at) - .to.be.ok; + expect(pinnedIds()).to.eql([]); + }); }); - it('message.deleted hard delete removes message from messagePaginator', function () { - const message = generateMsg({ id: 'hard-delete-message-id', silent: true }); - channel.messagePaginator.ingestItem(message); - expect(channel.messagePaginator.getItem(message.id)?.id).to.equal(message.id); + describe('message.deleted', () => { + it('message.delete removes quoted messages references', function () { + const originalMessage = generateMsg({ silent: true }); + channel._handleChannelEvent({ + type: 'message.new', + user: { id: 'id' }, + message: originalMessage, + }); - channel._handleChannelEvent({ - type: 'message.deleted', - user: { id: 'id' }, - hard_delete: true, - message, - }); + const quotingMessage = generateMsg({ + silent: true, + quoted_message: originalMessage, + quoted_message_id: originalMessage.id, + }); - expect( - channel.messagePaginator.items?.find((m) => m.id === message.id), - ).toBeUndefined(); - }); + channel._handleChannelEvent({ + type: 'message.new', + user: { id: 'id2' }, + message: quotingMessage, + }); - it('message.deleted soft delete updates message in messagePaginator', function () { - const message = generateMsg({ id: 'soft-delete-message-id', text: 'before delete' }); - channel.messagePaginator.ingestItem(message); + channel._handleChannelEvent({ + type: 'message.deleted', + user: { id: 'id' }, + message: { ...originalMessage, deleted_at: new Date().toISOString() }, + }); - const deletedAt = new Date().toISOString(); - channel._handleChannelEvent({ - type: 'message.deleted', - user: { id: 'id' }, - message: { ...message, deleted_at: deletedAt }, + expect( + channel.messagePaginator.getItem(quotingMessage.id).quoted_message.deleted_at, + ).to.be.ok; }); - const itemFromPaginator = channel.messagePaginator.getItem(message.id); - expect(itemFromPaginator?.deleted_at?.toISOString()).to.equal(deletedAt); - }); + it('message.deleted hard delete removes message from messagePaginator', function () { + const message = generateMsg({ id: 'hard-delete-message-id', silent: true }); + channel.messagePaginator.ingestItem(message); + expect(channel.messagePaginator.getItem(message.id)?.id).to.equal(message.id); - it('message.deleted (soft) ignores thread replies in messagePaginator', function () { - const parentMessage = generateMsg({ id: 'thread-parent-id-on-delete' }); - const threadReply = generateMsg({ - id: 'thread-reply-id-on-delete', - parent_id: parentMessage.id, - }); + channel._handleChannelEvent({ + type: 'message.deleted', + user: { id: 'id' }, + hard_delete: true, + message, + }); - channel.messagePaginator.ingestItem(parentMessage); - channel._handleChannelEvent({ - type: 'message.deleted', - user: { id: 'id' }, - message: { ...threadReply, deleted_at: new Date().toISOString() }, + expect( + channel.messagePaginator.items?.find((m) => m.id === message.id), + ).toBeUndefined(); }); - // A pure thread reply must never leak a "deleted" placeholder into the channel list. - expect(channel.messagePaginator.getItem(threadReply.id)).to.be.undefined; - }); + it('message.deleted soft delete updates message in messagePaginator', function () { + const message = generateMsg({ + id: 'soft-delete-message-id', + text: 'before delete', + }); + channel.messagePaginator.ingestItem(message); - it('message.deleted (hard) ignores thread replies in messagePaginator', function () { - const parentMessage = generateMsg({ id: 'thread-parent-id-on-hard-delete' }); - const threadReply = generateMsg({ - id: 'thread-reply-id-on-hard-delete', - parent_id: parentMessage.id, - }); + const deletedAt = new Date().toISOString(); + channel._handleChannelEvent({ + type: 'message.deleted', + user: { id: 'id' }, + message: { ...message, deleted_at: deletedAt }, + }); - channel.messagePaginator.ingestItem(parentMessage); - channel._handleChannelEvent({ - type: 'message.deleted', - user: { id: 'id' }, - hard_delete: true, - message: threadReply, + const itemFromPaginator = channel.messagePaginator.getItem(message.id); + expect(itemFromPaginator?.deleted_at?.toISOString()).to.equal(deletedAt); }); - expect(channel.messagePaginator.getItem(parentMessage.id)?.id).to.equal( - parentMessage.id, - ); - expect(channel.messagePaginator.getItem(threadReply.id)).to.be.undefined; - }); + it('message.deleted (soft) ignores thread replies in messagePaginator', function () { + const parentMessage = generateMsg({ id: 'thread-parent-id-on-delete' }); + const threadReply = generateMsg({ + id: 'thread-reply-id-on-delete', + parent_id: parentMessage.id, + }); - it('message.deleted syncs quoted_message references in messagePaginator', function () { - const quotedMessage = generateMsg({ - id: 'quoted-message-id-on-delete', - text: 'before delete', - }); - const quoteCarrier = generateMsg({ - id: 'quote-carrier-id-on-delete', - quoted_message_id: quotedMessage.id, - quoted_message: quotedMessage, + channel.messagePaginator.ingestItem(parentMessage); + channel._handleChannelEvent({ + type: 'message.deleted', + user: { id: 'id' }, + message: { ...threadReply, deleted_at: new Date().toISOString() }, + }); + + // A pure thread reply must never leak a "deleted" placeholder into the channel list. + expect(channel.messagePaginator.getItem(threadReply.id)).to.be.undefined; }); - channel.messagePaginator.setItems({ - valueOrFactory: [quotedMessage, quoteCarrier], - isFirstPage: true, - isLastPage: true, + it('message.deleted (hard) ignores thread replies in messagePaginator', function () { + const parentMessage = generateMsg({ id: 'thread-parent-id-on-hard-delete' }); + const threadReply = generateMsg({ + id: 'thread-reply-id-on-hard-delete', + parent_id: parentMessage.id, + }); + + channel.messagePaginator.ingestItem(parentMessage); + channel._handleChannelEvent({ + type: 'message.deleted', + user: { id: 'id' }, + hard_delete: true, + message: threadReply, + }); + + expect(channel.messagePaginator.getItem(parentMessage.id)?.id).to.equal( + parentMessage.id, + ); + expect(channel.messagePaginator.getItem(threadReply.id)).to.be.undefined; }); - channel._handleChannelEvent({ - type: 'message.deleted', - user: { id: 'id' }, - message: { - ...quotedMessage, - type: 'deleted', - text: 'after delete', - deleted_at: new Date().toISOString(), - }, - }); + it('message.deleted syncs quoted_message references in messagePaginator', function () { + const quotedMessage = generateMsg({ + id: 'quoted-message-id-on-delete', + text: 'before delete', + }); + const quoteCarrier = generateMsg({ + id: 'quote-carrier-id-on-delete', + quoted_message_id: quotedMessage.id, + quoted_message: quotedMessage, + }); - expect( - channel.messagePaginator.getItem(quoteCarrier.id)?.quoted_message?.type, - ).to.equal('deleted'); - }); + channel.messagePaginator.setItems({ + valueOrFactory: [quotedMessage, quoteCarrier], + isFirstPage: true, + isLastPage: true, + }); - it('reaction.new ingests message into messagePaginator for non-thread messages', function () { - const message = generateMsg({ id: 'reaction-channel-message-id' }); + channel._handleChannelEvent({ + type: 'message.deleted', + user: { id: 'id' }, + message: { + ...quotedMessage, + type: 'deleted', + text: 'after delete', + deleted_at: new Date().toISOString(), + }, + }); - channel._handleChannelEvent({ - type: 'reaction.new', - message, - reaction: { - type: 'love', - user_id: 'user-1', - message_id: message.id, - created_at: new Date().toISOString(), - }, + expect( + channel.messagePaginator.getItem(quoteCarrier.id)?.quoted_message?.type, + ).to.equal('deleted'); }); - expect(channel.messagePaginator.getItem(message.id)?.id).to.equal(message.id); - }); + it('removes a pinned message on hard delete', () => { + const msg = makePinned('p', '2020-01-01T00:00:00.000Z'); + seedPinned([msg]); - it('reaction.new ignores thread replies in messagePaginator', function () { - const message = generateMsg({ - id: 'reaction-thread-message-id', - parent_id: 'thread-parent-id', - }); + channel._handleChannelEvent({ + type: 'message.deleted', + message: msg, + hard_delete: true, + }); - channel._handleChannelEvent({ - type: 'reaction.new', - message, - reaction: { - type: 'love', - user_id: 'user-1', - message_id: message.id, - created_at: new Date().toISOString(), - }, + expect(pinnedIds()).to.not.include('p'); }); - - expect(channel.messagePaginator.getItem(message.id)).to.be.undefined; }); - ['reaction.deleted', 'reaction.updated'].forEach((eventType) => { - it(`${eventType} ingests message into messagePaginator for non-thread messages`, function () { - const message = generateMsg({ id: `${eventType}-channel-message-id` }); + describe('reaction.new', () => { + it('reaction.new ingests message into messagePaginator for non-thread messages', function () { + const message = generateMsg({ id: 'reaction-channel-message-id' }); channel._handleChannelEvent({ - type: eventType, + type: 'reaction.new', message, reaction: { type: 'love', @@ -1078,14 +1206,14 @@ describe('Channel _handleChannelEvent', function () { expect(channel.messagePaginator.getItem(message.id)?.id).to.equal(message.id); }); - it(`${eventType} ignores thread replies in messagePaginator`, function () { + it('reaction.new ignores thread replies in messagePaginator', function () { const message = generateMsg({ - id: `${eventType}-thread-message-id`, + id: 'reaction-thread-message-id', parent_id: 'thread-parent-id', }); channel._handleChannelEvent({ - type: eventType, + type: 'reaction.new', message, reaction: { type: 'love', @@ -1097,165 +1225,72 @@ describe('Channel _handleChannelEvent', function () { expect(channel.messagePaginator.getItem(message.id)).to.be.undefined; }); - }); - - describe('user.messages.deleted', () => { - const bannedUser = { id: 'banned-user' }; - const otherUser = { id: 'other-user' }; - const messageSet1 = [ - { - attachments: [ - { - type: 'image', - title: 'YouTube', - title_link: 'https://www.youtube.com/', - text: 'Enjoy the videos and music you love, upload original content, and share it all with friends, family, and the world on YouTube.', - image_url: 'https://www.youtube.com/img/desktop/yt_1200.png', - thumb_url: 'https://www.youtube.com/img/desktop/yt_1200.png', - og_scrape_url: 'https://www.youtube.com/', - }, - ], - created_at: '2021-01-01T00:01:00', - pinned: true, - pinned_at: '2022-01-01T00:01:00', - user: bannedUser, - }, - { - created_at: '2021-01-01T00:02:00', - pinned: true, - pinned_at: '2022-01-01T00:02:00', - user: otherUser, - }, - { created_at: '2021-01-01T00:03:00', user: bannedUser }, - ].map(generateMsg); - - const quoted_message = messageSet1[0]; - const messageSet2 = [ - { - created_at: '2020-01-01T00:01:00', - pinned: true, - pinned_at: '2022-01-01T00:03:00', - user: bannedUser, - }, - { - created_at: '2020-01-01T00:02:00', - quoted_message, - quoted_message_id: quoted_message.id, - user: otherUser, - }, - { created_at: '2020-01-01T00:03:00', user: bannedUser }, - { created_at: '2020-01-01T00:04:00', user: otherUser }, - ].map(generateMsg); - - const parent_id = messageSet2[0].id; - const thread1 = [ - { created_at: '2020-01-01T00:01:30', parent_id, user: bannedUser, type: 'reply' }, - { created_at: '2020-01-01T00:02:35', parent_id, user: otherUser, type: 'reply' }, - { created_at: '2020-01-01T00:03:45', parent_id, user: bannedUser, type: 'reply' }, - { created_at: '2020-01-01T00:04:00', parent_id, user: otherUser, type: 'reply' }, - ]; - const pinnedMessages = [messageSet1[0], messageSet1[1], messageSet2[0]]; + it('reflects a reaction on a pinned message', () => { + const msg = makePinned('p', '2020-01-01T00:00:00.000Z', { own_reactions: [] }); + seedPinned([msg]); - // Thread-reply deletion is owned by the Thread object (covered in threads.test.ts); this - // suite exercises the pinned-message cache that ChannelState still keeps. - const setupChannel = (channel) => { - channel.state.addPinnedMessages(pinnedMessages); - }; + channel._handleChannelEvent({ + type: 'reaction.new', + message: { ...msg, own_reactions: [] }, + reaction: { + type: 'like', + user_id: user.id, + message_id: 'p', + created_at: new Date().toISOString(), + }, + }); - it('removes the pinned messages on hard delete', () => { - setupChannel(channel); - expect(channel.state.pinnedMessages).toHaveLength(pinnedMessages.length); + const item = channel.pinnedMessagesPaginator.getItem('p'); + expect(item?.own_reactions?.some((r) => r.type === 'like')).to.be.true; + }); + }); - const event = { - type: 'user.messages.deleted', - cid: channel.cid, - channel_type: channel.type, - channel_id: channel.id, - user: bannedUser, - hard_delete: true, - created_at: '2025-02-01T14:01:30.000Z', - }; - channel._handleChannelEvent(event); + describe('reaction.deleted', () => { + // The parametrized cases also cover reaction.updated. + ['reaction.deleted', 'reaction.updated'].forEach((eventType) => { + it(`${eventType} ingests message into messagePaginator for non-thread messages`, function () { + const message = generateMsg({ id: `${eventType}-channel-message-id` }); - const check = (message) => { - const deletedMessage = { - attachments: [], - cid: message.cid, - created_at: message.created_at, - deleted_at: new Date(event.created_at), - id: message.id, - latest_reactions: [], - mentioned_users: [], - own_reactions: [], - parent_id: message.parent_id, - reply_count: message.reply_count, - status: message.status, - thread_participants: message.thread_participants, - type: 'deleted', - updated_at: message.updated_at, - user: message.user, - }; - if (message.user.id === bannedUser.id) { - expect(message).toStrictEqual(deletedMessage); - } else if (message.quoted_message) { - expect(message).toStrictEqual({ - ...message, - quoted_message: { - ...deletedMessage, - id: message.quoted_message.id, - user: message.quoted_message.user, - created_at: message.quoted_message.created_at, - updated_at: message.quoted_message.updated_at, - }, - }); - } else { - expect(message).toEqual(message); - } - }; + channel._handleChannelEvent({ + type: eventType, + message, + reaction: { + type: 'love', + user_id: 'user-1', + message_id: message.id, + created_at: new Date().toISOString(), + }, + }); - channel.state.pinnedMessages.forEach(check); - }); - it('removes the pinned messages on soft delete', () => { - setupChannel(channel); - expect(channel.state.pinnedMessages).toHaveLength(pinnedMessages.length); + expect(channel.messagePaginator.getItem(message.id)?.id).to.equal(message.id); + }); - const event = { - type: 'user.messages.deleted', - cid: channel.cid, - channel_type: channel.type, - channel_id: channel.id, - user: bannedUser, - soft_delete: true, - created_at: '2025-02-01T14:01:30.000Z', - }; - channel._handleChannelEvent(event); + it(`${eventType} ignores thread replies in messagePaginator`, function () { + const message = generateMsg({ + id: `${eventType}-thread-message-id`, + parent_id: 'thread-parent-id', + }); - const check = (message) => { - if (message.user.id === bannedUser.id) { - expect(message).toStrictEqual({ - ...message, - attachments: [], - deleted_at: new Date(event.created_at), - type: 'deleted', - }); - } else if (message.quoted_message) { - expect(message).toStrictEqual({ - ...message, - quoted_message: { - ...message.quoted_message, - attachments: [], - deleted_at: new Date(event.created_at), - type: 'deleted', - }, - }); - } else { - expect(message).toEqual(message); - } - }; + channel._handleChannelEvent({ + type: eventType, + message, + reaction: { + type: 'love', + user_id: 'user-1', + message_id: message.id, + created_at: new Date().toISOString(), + }, + }); - channel.state.pinnedMessages.forEach(check); + expect(channel.messagePaginator.getItem(message.id)).to.be.undefined; + }); }); + }); + + describe('user.messages.deleted', () => { + const bannedUser = { id: 'banned-user' }; + const otherUser = { id: 'other-user' }; it('updates messagePaginator items on soft delete', () => { const deletedAt = new Date('2025-02-01T14:01:30.000Z'); @@ -1329,12 +1364,42 @@ describe('Channel _handleChannelEvent', function () { quoteCarrierFromPaginator?.quoted_message?.deleted_at?.toISOString(), ).to.equal(deletedAt.toISOString()); }); + + // Pinned-message deletion for a banned user (moved from the pinnedMessagesPaginator suite). + it("marks a banned user's pinned messages deleted on user.messages.deleted (soft)", () => { + seedPinned([makePinned('p', '2020-01-01T00:00:00.000Z', { user: bannedUser })]); + + channel._handleChannelEvent({ + type: 'user.messages.deleted', + user: bannedUser, + soft_delete: true, + created_at: '2025-01-01T00:00:00.000Z', + }); + + expect(channel.pinnedMessagesPaginator.getItem('p')?.type).to.equal('deleted'); + }); + + it("removes a banned user's pinned messages on user.messages.deleted (hard)", () => { + seedPinned([ + makePinned('p', '2020-01-01T00:00:00.000Z', { user: bannedUser }), + makePinned('other', '2020-01-02T00:00:00.000Z', { user: otherUser }), + ]); + + channel._handleChannelEvent({ + type: 'user.messages.deleted', + user: bannedUser, + hard_delete: true, + created_at: '2025-01-01T00:00:00.000Z', + }); + + expect(channel.pinnedMessagesPaginator.items?.map((m) => m.id)).to.eql(['other']); + }); }); // Regression coverage for GetStream/stream-chat-js#1736 at the per-channel event entry point - // (channel.ts → _handleChannelEvent → state.deleteUserMessages). Mirrors the global-event - // regression suite in client.test.js but exercises the channel-scoped user.messages.deleted - // event (one carrying a cid). + // (channel.ts → _handleChannelEvent → messagePaginator.applyMessageDeletionForUser). Mirrors + // the global-event regression suite in client.test.js but exercises the channel-scoped + // user.messages.deleted event (one carrying a cid). describe('user.messages.deleted — quoted_message regression (#1736)', () => { const bannedUser = { id: 'banned-user' }; @@ -1774,382 +1839,270 @@ describe('Channel _handleChannelEvent', function () { client.messageDeliveryReporter.deliveryReportCandidates.get(channel.cid), ).toBe(messageDeliveredEvent.last_delivered_message_id); }); - }); - - it('should include unread_messages for message events from another user', () => { - channel.state.read['id'] = { - unread_messages: 2, - }; - const message = generateMsg(); - - const events = [ - 'message.read', - 'message.deleted', - 'message.new', - 'message.updated', - 'member.added', - 'member.updated', - 'member.removed', - ]; - - for (const event of events) { - channel.state.read['id'].unread_messages = 2; - channel._handleChannelEvent({ - type: event, - user: { id: 'id' }, - message, - }); - expect(channel.state.read['id'].unread_messages, `${event} should not be undefined`) - .not.to.be.undefined; - } + it('does not override the delivery information in the read status', () => {}); }); - it('should include unread_messages for message events from the current user', () => { - channel.state.read[client.user.id] = { - unread_messages: 2, - }; - - const message = generateMsg({ user: { id: client.userID } }); - - const events = [ - 'message.read', - 'message.deleted', - 'message.new', - 'message.updated', - 'member.added', - 'member.updated', - 'member.removed', - ]; - - for (const event of events) { - channel.state.read['id'] = { - unread_messages: 2, + describe('channel.visible', () => { + it('should mark channel visible on channel.visible event', () => { + const channelVisibleEvent = { + channel: { + blocked: false, + }, + type: 'channel.visible', + cid: 'messaging:id', + channel_id: 'id', + channel_type: 'messaging', + user: { + id: 'admin', + role: 'admin', + created_at: '2022-03-08T09:46:56.840739Z', + updated_at: '2022-03-15T08:30:09.796926Z', + last_active: '2023-05-24T09:20:31.041292724Z', + banned: false, + online: true, + }, + created_at: '2023-05-24T09:20:43.986615426Z', }; + channel.data.hidden = true; + channel.data.blocked = true; - channel._handleChannelEvent({ - type: event, - user: { id: client.user.id }, - message, - }); - expect( - channel.state.read[client.user.id].unread_messages, - `${event} should not be undefined`, - ).not.to.be.undefined; - } - }); - - it('should extend "message.updated" and "message.deleted" event payloads with "own_reactions"', () => { - const own_reactions = [ - { - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - type: 'wow', - }, - ]; - // Thread-reply own_reactions preservation is owned by the Thread object (covered in - // threads.test.ts); at the channel level only the paginator-backed message list is enriched. - const message = generateMsg({ own_reactions }); - seedLatestWindow(channel, [message]); - - ['message.updated', 'message.deleted'].forEach((eventType) => { - let receivedEvent; - channel.on(eventType, (e) => (receivedEvent = e)); - - const event = { - type: eventType, - // own_reactions is always [] in WS events - message: { ...message, own_reactions: [] }, - }; - channel._handleChannelEvent(event); - channel._callChannelListeners(event); - - const stored = channel.messagePaginator.getItem(message.id); - expect(stored.own_reactions.length).to.equal(own_reactions.length); - expect(receivedEvent.message.own_reactions.length).to.equal(own_reactions.length); - }); - }); - - it('should update quoted_message references on "message.updated" and "message.deleted" event', () => { - // Thread-reply quoted-message updates are owned by the Thread object (Thread.messagePaginator - // .reflectQuotedMessageUpdate); this exercises the channel's paginator-backed message list. - const originalText = 'XX'; - const updatedText = 'YY'; - const quoted_message = generateMsg({ - date: new Date(2).toISOString(), - id: 'quoted-message', - text: originalText, - }); - const quotingMessage = generateMsg({ - date: new Date(3).toISOString(), - id: 'quoting-message', - quoted_message, - quoted_message_id: quoted_message.id, - }); - const updatedQuotedMessage = { ...quoted_message, text: updatedText }; - ['message.updated', 'message.deleted'].forEach((eventType) => { - seedLatestWindow(channel, [quoted_message, quotingMessage]); - const event = { type: eventType, message: updatedQuotedMessage }; - channel._handleChannelEvent(event); - const stored = channel.messagePaginator.getItem(quotingMessage.id); - expect(stored.quoted_message.text).to.equal(updatedQuotedMessage.text); - channel.messagePaginator.clearStateAndCache(); + channel._handleChannelEvent(channelVisibleEvent); + expect(channel.data.hidden).eq(false); + expect(channel.data.blocked).eq(false); }); - }); - it('should mark channel visible on channel.visible event', () => { - const channelVisibleEvent = { - channel: { - blocked: false, - }, - type: 'channel.visible', - cid: 'messaging:id', - channel_id: 'id', - channel_type: 'messaging', - user: { - id: 'admin', - role: 'admin', - created_at: '2022-03-08T09:46:56.840739Z', - updated_at: '2022-03-15T08:30:09.796926Z', - last_active: '2023-05-24T09:20:31.041292724Z', - banned: false, - online: true, - }, - created_at: '2023-05-24T09:20:43.986615426Z', - }; - channel.data.hidden = true; - channel.data.blocked = true; + it('should treat blocked separately from hidden on channel.visible event', () => { + const channelVisibleEvent = { + channel: { + blocked: true, + }, + type: 'channel.visible', + cid: 'messaging:id', + channel_id: 'id', + channel_type: 'messaging', + user: { + id: 'admin', + role: 'admin', + created_at: '2022-03-08T09:46:56.840739Z', + updated_at: '2022-03-15T08:30:09.796926Z', + last_active: '2023-05-24T09:20:31.041292724Z', + banned: false, + online: true, + }, + created_at: '2023-05-24T09:20:43.986615426Z', + }; + channel.data.hidden = true; + channel.data.blocked = true; - channel._handleChannelEvent(channelVisibleEvent); - expect(channel.data.hidden).eq(false); - expect(channel.data.blocked).eq(false); + channel._handleChannelEvent(channelVisibleEvent); + expect(channel.data.hidden).eq(false); + expect(channel.data.blocked).eq(true); + }); }); - it('should treat blocked separately from hidden on channel.visible event', () => { - const channelVisibleEvent = { - channel: { - blocked: true, - }, - type: 'channel.visible', - cid: 'messaging:id', - channel_id: 'id', - channel_type: 'messaging', - user: { - id: 'admin', - role: 'admin', - created_at: '2022-03-08T09:46:56.840739Z', - updated_at: '2022-03-15T08:30:09.796926Z', - last_active: '2023-05-24T09:20:31.041292724Z', - banned: false, - online: true, - }, - created_at: '2023-05-24T09:20:43.986615426Z', - }; - channel.data.hidden = true; - channel.data.blocked = true; + describe('channel.hidden', () => { + it('should mark channel hidden on channel.hidden event', () => { + const channelVisibleEvent = { + channel: { + blocked: true, + }, + type: 'channel.hidden', + }; + channel.data.hidden = false; + channel.data.blocked = false; - channel._handleChannelEvent(channelVisibleEvent); - expect(channel.data.hidden).eq(false); - expect(channel.data.blocked).eq(true); - }); + channel._handleChannelEvent(channelVisibleEvent); + expect(channel.data.hidden).eq(true); + expect(channel.data.blocked).eq(true); + }); - it('should mark channel hidden on channel.hidden event', () => { - const channelVisibleEvent = { - channel: { - blocked: true, - }, - type: 'channel.hidden', - }; - channel.data.hidden = false; - channel.data.blocked = false; + it('should treat blocked separately from hidden on channel.hidden event', () => { + const channelVisibleEvent = { + channel: { + blocked: false, + }, + type: 'channel.hidden', + }; + channel.data.hidden = false; + channel.data.blocked = false; - channel._handleChannelEvent(channelVisibleEvent); - expect(channel.data.hidden).eq(true); - expect(channel.data.blocked).eq(true); + channel._handleChannelEvent(channelVisibleEvent); + expect(channel.data.hidden).eq(true); + expect(channel.data.blocked).eq(false); + }); }); - it('should treat blocked separately from hidden on channel.hidden event', () => { - const channelVisibleEvent = { - channel: { - blocked: false, - }, - type: 'channel.hidden', - }; - channel.data.hidden = false; - channel.data.blocked = false; - - channel._handleChannelEvent(channelVisibleEvent); - expect(channel.data.hidden).eq(true); - expect(channel.data.blocked).eq(false); - }); + describe('channel.updated', () => { + it('should update the frozen flag and reload channel state when frozen changes', () => { + const event = { + channel: { frozen: true }, + type: 'channel.updated', + }; + channel.data.frozen = false; + const channelQuerySpy = vi.spyOn(channel, 'query'); - it('should update the frozen flag and reload channel state when frozen changes', () => { - const event = { - channel: { frozen: true }, - type: 'channel.updated', - }; - channel.data.frozen = false; - const channelQuerySpy = vi.spyOn(channel, 'query'); + channel._handleChannelEvent(event); + expect(channel.data.frozen).eq(true); + expect(channelQuerySpy).toHaveBeenCalledTimes(1); - channel._handleChannelEvent(event); - expect(channel.data.frozen).eq(true); - expect(channelQuerySpy).toHaveBeenCalledTimes(1); + channel._handleChannelEvent(event); + expect(channelQuerySpy).toHaveBeenCalledTimes(1); - channel._handleChannelEvent(event); - expect(channelQuerySpy).toHaveBeenCalledTimes(1); + // Make sure that we don't wipe out any data + }); - // Make sure that we don't wipe out any data - }); + it('channel.updated updates member_count from the event channel data', () => { + channel.data = { member_count: 5 }; - it('channel.updated updates member_count from the event channel data', () => { - channel.data = { member_count: 5 }; + channel._handleChannelEvent({ + type: 'channel.updated', + channel: { member_count: 10 }, + }); - channel._handleChannelEvent({ - type: 'channel.updated', - channel: { member_count: 10 }, + expect(channel.data.member_count).to.equal(10); }); - expect(channel.data.member_count).to.equal(10); - }); + it('preserves member_count on channel.updated when event payload omits member_count', () => { + channel.data.member_count = 3; + channel.data.frozen = false; + channel._handleChannelEvent({ + channel: { frozen: false }, + type: 'channel.updated', + }); - it('preserves member_count on channel.updated when event payload omits member_count', () => { - channel.data.member_count = 3; - channel.data.frozen = false; - channel._handleChannelEvent({ - channel: { frozen: false }, - type: 'channel.updated', + expect(channel.data.member_count).to.equal(3); + expect(channel.state.member_count).to.equal(3); }); - expect(channel.data.member_count).to.equal(3); - expect(channel.state.member_count).to.equal(3); - }); - - it(`should make sure that state reload doesn't wipe out existing data`, async () => { - const mock = sinon.mock(client); - mock.expects('post').returns(Promise.resolve(mockChannelQueryResponse)); + it(`should make sure that state reload doesn't wipe out existing data`, async () => { + const mock = sinon.mock(client); + mock.expects('post').returns(Promise.resolve(mockChannelQueryResponse)); - channel.state.members = { - user: { id: 'user' }, - }; - channel.state.watchers = { - user: { id: 'user' }, - }; - channel.state.read = { - user: { id: 'user' }, - }; - seedLatestWindow(channel, [generateMsg()]); - channel.state.addPinnedMessages([generateMsg()]); - channel.state.watcher_count = 5; + channel.state.members = { + user: { id: 'user' }, + }; + channel.state.watchers = { + user: { id: 'user' }, + }; + channel.state.read = { + user: { id: 'user' }, + }; + seedLatestWindow(channel, [generateMsg()]); + channel.state.watcher_count = 5; - await channel.query(); + await channel.query(); - expect(Object.keys(channel.state.members).length).to.be.eq(1); - expect(Object.keys(channel.state.watchers).length).to.be.eq(1); - expect(Object.keys(channel.state.read).length).to.be.eq(1); - expect(channel.messagePaginator.latestItems.length).to.be.eq(1); - expect(channel.state.pinnedMessages.length).to.be.eq(1); - expect(channel.state.watcher_count).to.be.eq(5); - }); + expect(Object.keys(channel.state.members).length).to.be.eq(1); + expect(Object.keys(channel.state.watchers).length).to.be.eq(1); + expect(Object.keys(channel.state.read).length).to.be.eq(1); + expect(channel.messagePaginator.latestItems.length).to.be.eq(1); + expect(channel.state.watcher_count).to.be.eq(5); + }); - it('should dispatch "capabilities.changed" event', async () => { - const mock = sinon.mock(client); - const response = mockChannelQueryResponse; - channel.data.own_capabilities = response.channel.own_capabilities.slice(0, 1); - mock.expects('post').returns(Promise.resolve(response)); - const spy = sinon.spy(); - channel.on('capabilities.changed', spy); + // capabilities.changed is emitted from the channel.updated / query path. + it('should dispatch "capabilities.changed" event', async () => { + const mock = sinon.mock(client); + const response = mockChannelQueryResponse; + channel.data.own_capabilities = response.channel.own_capabilities.slice(0, 1); + mock.expects('post').returns(Promise.resolve(response)); + const spy = sinon.spy(); + channel.on('capabilities.changed', spy); - await channel.query(); + await channel.query(); - expect(spy.calledOnce).to.be.true; + expect(spy.calledOnce).to.be.true; - const arg = spy.firstCall.args[0]; - // We don't care about received_at in the assertion - delete arg.received_at; - sinon.assert.match(arg, { - type: 'capabilities.changed', - cid: channel.cid, - own_capabilities: response.channel.own_capabilities, - }); + const arg = spy.firstCall.args[0]; + // We don't care about received_at in the assertion + delete arg.received_at; + sinon.assert.match(arg, { + type: 'capabilities.changed', + cid: channel.cid, + own_capabilities: response.channel.own_capabilities, + }); - channel.data.own_capabilities = response.channel.own_capabilities; - mock.expects('post').returns(Promise.resolve(response)); - spy.resetHistory(); + channel.data.own_capabilities = response.channel.own_capabilities; + mock.expects('post').returns(Promise.resolve(response)); + spy.resetHistory(); - await channel.query(); + await channel.query(); - expect(spy.notCalled).to.be.true; + expect(spy.notCalled).to.be.true; + }); }); - it('should update channel member ban state on user.banned and user.unbanned events', () => { - const user = { id: 'user_id' }; - const shadowBanEvent = { - type: 'user.banned', - shadow: true, - user, - }; - const shadowUnbanEvent = { - type: 'user.unbanned', - shadow: true, - user, - }; - const banEvent = { - type: 'user.banned', - user, - }; - const unbanEvent = { - type: 'user.unbanned', - user, - }; + describe('user.banned / user.unbanned', () => { + it('should update channel member ban state on user.banned and user.unbanned events', () => { + const user = { id: 'user_id' }; + const shadowBanEvent = { + type: 'user.banned', + shadow: true, + user, + }; + const shadowUnbanEvent = { + type: 'user.unbanned', + shadow: true, + user, + }; + const banEvent = { + type: 'user.banned', + user, + }; + const unbanEvent = { + type: 'user.unbanned', + user, + }; - [ - [ - shadowBanEvent, - banEvent, - { shadow_banned: true, banned: false }, - { shadow_banned: false, banned: true }, - ], - [ - shadowBanEvent, - shadowUnbanEvent, - { shadow_banned: true, banned: false }, - { shadow_banned: false, banned: false }, - ], - [ - shadowBanEvent, - unbanEvent, - { shadow_banned: true, banned: false }, - { shadow_banned: false, banned: false }, - ], [ - banEvent, - shadowBanEvent, - { shadow_banned: false, banned: true }, - { shadow_banned: true, banned: false }, - ], - [ - banEvent, - shadowUnbanEvent, - { shadow_banned: false, banned: true }, - { shadow_banned: false, banned: false }, - ], - [ - banEvent, - unbanEvent, - { shadow_banned: false, banned: true }, - { shadow_banned: false, banned: false }, - ], - ].forEach(([firstEvent, secondEvent, expectAfterFirst, expectAfterSecond]) => { - channel._handleChannelEvent(firstEvent); - expect(channel.state.members[user.id].banned).eq(expectAfterFirst.banned); - expect(channel.state.members[user.id].shadow_banned).eq( - expectAfterFirst.shadow_banned, - ); - channel._handleChannelEvent(secondEvent); - expect(channel.state.members[user.id].banned).eq(expectAfterSecond.banned); - expect(channel.state.members[user.id].shadow_banned).eq( - expectAfterSecond.shadow_banned, - ); + [ + shadowBanEvent, + banEvent, + { shadow_banned: true, banned: false }, + { shadow_banned: false, banned: true }, + ], + [ + shadowBanEvent, + shadowUnbanEvent, + { shadow_banned: true, banned: false }, + { shadow_banned: false, banned: false }, + ], + [ + shadowBanEvent, + unbanEvent, + { shadow_banned: true, banned: false }, + { shadow_banned: false, banned: false }, + ], + [ + banEvent, + shadowBanEvent, + { shadow_banned: false, banned: true }, + { shadow_banned: true, banned: false }, + ], + [ + banEvent, + shadowUnbanEvent, + { shadow_banned: false, banned: true }, + { shadow_banned: false, banned: false }, + ], + [ + banEvent, + unbanEvent, + { shadow_banned: false, banned: true }, + { shadow_banned: false, banned: false }, + ], + ].forEach(([firstEvent, secondEvent, expectAfterFirst, expectAfterSecond]) => { + channel._handleChannelEvent(firstEvent); + expect(channel.state.members[user.id].banned).eq(expectAfterFirst.banned); + expect(channel.state.members[user.id].shadow_banned).eq( + expectAfterFirst.shadow_banned, + ); + channel._handleChannelEvent(secondEvent); + expect(channel.state.members[user.id].banned).eq(expectAfterSecond.banned); + expect(channel.state.members[user.id].shadow_banned).eq( + expectAfterSecond.shadow_banned, + ); + }); }); }); }); diff --git a/test/unit/channel_state.test.js b/test/unit/channel_state.test.js index 90796cc19..c664f88eb 100644 --- a/test/unit/channel_state.test.js +++ b/test/unit/channel_state.test.js @@ -1,6 +1,5 @@ import { generateChannel } from './test-utils/generateChannel'; import { generateMsg } from './test-utils/generateMessage'; -import { generateUser } from './test-utils/generateUser'; import { getClientWithUser } from './test-utils/getClient'; import { getOrCreateChannelApi } from './test-utils/getOrCreateChannelApi'; @@ -43,25 +42,6 @@ describe('ChannelState addMessagesSorted', function () { new Date('2020-01-01T00:00:00.001Z').getTime(), ); }); - - it('sets pinnedMessages correctly', async function () { - const msgs = [ - generateMsg({ id: '1', date: '2020-01-01T00:00:00.001Z' }), - generateMsg({ id: '2', date: '2020-01-01T00:00:00.002Z' }), - generateMsg({ id: '3', date: '2020-01-01T00:00:00.003Z' }), - ]; - msgs[0].pinned = true; - msgs[0].pinned_at = new Date('2020-01-01T00:00:00.010Z'); - msgs[1].pinned = true; - msgs[1].pinned_at = new Date('2020-01-01T00:00:00.012Z'); - msgs[2].pinned = true; - msgs[2].pinned_at = new Date('2020-01-01T00:00:00.011Z'); - state.addPinnedMessages(msgs); - expect(state.pinnedMessages.length).to.be.equal(3); - expect(state.pinnedMessages[0].id).to.be.equal('1'); - expect(state.pinnedMessages[1].id).to.be.equal('3'); - expect(state.pinnedMessages[2].id).to.be.equal('2'); - }); }); describe('ChannelState isUpToDate', () => { @@ -127,227 +107,6 @@ describe('ChannelState clean', () => { }); }); -describe('deleteUserMessages', () => { - let state; - - beforeEach(() => { - const client = new StreamChat(); - client.userID = 'userId'; - const channel = new Channel(client, 'type', 'id', {}); - client._addChannelConfig({ cid: channel.cid, config: {} }); - state = new ChannelState(channel); - }); - - it('should remove content of pinned messages from given user, when hardDelete is true', () => { - const user1 = generateUser(); - const user2 = generateUser(); - - const m1u1 = generateMsg({ - user: user1, - pinned: true, - pinned_at: new Date('2020-01-01T00:00:00.001Z'), - }); - const m2u1 = generateMsg({ - user: user1, - pinned: true, - pinned_at: new Date('2020-01-01T00:00:00.002Z'), - }); - const m1u2 = generateMsg({ - user: user2, - pinned: true, - pinned_at: new Date('2020-01-01T00:00:00.003Z'), - }); - const m2u2 = generateMsg({ - user: user2, - pinned: true, - pinned_at: new Date('2020-01-01T00:00:00.004Z'), - }); - - state.addPinnedMessages([m1u1, m2u1, m1u2, m2u2]); - - expect(state.pinnedMessages).to.have.length(4); - - state.deleteUserMessages(user1, true); - - const byId = (id) => state.pinnedMessages.find((m) => m.id === id); - - expect(state.pinnedMessages).to.have.length(4); - - expect(byId(m1u1.id).type).to.be.equal('deleted'); - expect(byId(m1u1.id).text).to.be.equal(undefined); - expect(byId(m1u1.id).html).to.be.equal(undefined); - - expect(byId(m2u1.id).type).to.be.equal('deleted'); - expect(byId(m2u1.id).text).to.be.equal(undefined); - expect(byId(m2u1.id).html).to.be.equal(undefined); - - expect(byId(m1u2.id).type).to.be.equal('regular'); - expect(byId(m1u2.id).text).to.be.equal(m1u2.text); - expect(byId(m1u2.id).html).to.be.equal(m1u2.html); - - expect(byId(m2u2.id).type).to.be.equal('regular'); - expect(byId(m2u2.id).text).to.be.equal(m2u2.text); - expect(byId(m2u2.id).html).to.be.equal(m2u2.html); - }); - it('should mark pinned messages from given user as deleted, when hardDelete is false', () => { - const user1 = generateUser(); - const user2 = generateUser(); - - const m1u1 = generateMsg({ - user: user1, - pinned: true, - pinned_at: new Date('2020-01-01T00:00:00.001Z'), - }); - const m2u1 = generateMsg({ - user: user1, - pinned: true, - pinned_at: new Date('2020-01-01T00:00:00.002Z'), - }); - const m1u2 = generateMsg({ - user: user2, - pinned: true, - pinned_at: new Date('2020-01-01T00:00:00.003Z'), - }); - const m2u2 = generateMsg({ - user: user2, - pinned: true, - pinned_at: new Date('2020-01-01T00:00:00.004Z'), - }); - - state.addPinnedMessages([m1u1, m2u1, m1u2, m2u2]); - expect(state.pinnedMessages).to.have.length(4); - - state.deleteUserMessages(user1); - - const byId = (id) => state.pinnedMessages.find((m) => m.id === id); - - expect(state.pinnedMessages).to.have.length(4); - - expect(byId(m1u1.id).type).to.be.equal('deleted'); - expect(byId(m1u1.id).text).to.be.equal(m1u1.text); - expect(byId(m1u1.id).html).to.be.equal(m1u1.html); - - expect(byId(m2u1.id).type).to.be.equal('deleted'); - expect(byId(m2u1.id).text).to.be.equal(m2u1.text); - expect(byId(m2u1.id).html).to.be.equal(m2u1.html); - - expect(byId(m1u2.id).type).to.be.equal('regular'); - expect(byId(m1u2.id).text).to.be.equal(m1u2.text); - expect(byId(m1u2.id).html).to.be.equal(m1u2.html); - - expect(byId(m2u2.id).type).to.be.equal('regular'); - expect(byId(m2u2.id).text).to.be.equal(m2u2.text); - expect(byId(m2u2.id).html).to.be.equal(m2u2.html); - }); -}); - -// Regression tests for GetStream/stream-chat-js#1736: -// deleteUserMessages crashed with "Cannot read property 'cid' of undefined" when -// hard-deleting a message that quotes another message from the same user. The first -// branch replaced messages[i] with the stripped hard-delete placeholder (no -// quoted_message field); the second branch then read messages[i].quoted_message as -// undefined and passed it into toDeletedMessage, which dereferences message.cid. -describe('deleteUserMessages — quoted_message regression (#1736)', () => { - let state; - - beforeEach(() => { - const client = new StreamChat(); - client.userID = 'userId'; - const channel = new Channel(client, 'type', 'id', {}); - client._addChannelConfig({ cid: channel.cid, config: {} }); - state = new ChannelState(channel); - }); - - it('does not throw when hard-deleting a pinned message that quotes another same-user pinned message', () => { - const user1 = generateUser(); - const m1 = generateMsg({ - user: user1, - pinned: true, - pinned_at: new Date('2022-01-01T00:00:00.001Z'), - }); - const m2 = generateMsg({ - user: user1, - pinned: true, - pinned_at: new Date('2022-01-01T00:00:00.002Z'), - quoted_message: m1, - quoted_message_id: m1.id, - }); - - state.addMessagesSorted([m1, m2]); - state.addPinnedMessages([m1, m2]); - expect(state.pinnedMessages).to.have.length(2); - - expect(() => state.deleteUserMessages(user1, true)).not.to.throw(); - - expect(state.pinnedMessages).to.have.length(2); - state.pinnedMessages.forEach((message) => { - expect(message.type).to.be.equal('deleted'); - }); - const pinnedQuoter = state.pinnedMessages.find((m) => m.id === m2.id); - expect(pinnedQuoter.quoted_message).to.be.equal(undefined); - }); -}); - -describe('updateUserMessages', () => { - let state; - - beforeEach(() => { - const client = new StreamChat(); - client.userID = 'userId'; - const channel = new Channel(client, 'type', 'id', {}); - client._addChannelConfig({ cid: channel.cid, config: {} }); - state = new ChannelState(channel); - }); - - it('should update user property of pinned messages from given user', () => { - let user1 = generateUser(); - const user2 = generateUser(); - - const m1u1 = generateMsg({ - user: user1, - pinned: true, - pinned_at: new Date('2020-01-01T00:00:00.001Z'), - }); - const m2u1 = generateMsg({ - user: user1, - pinned: true, - pinned_at: new Date('2020-01-01T00:00:00.002Z'), - }); - const m1u2 = generateMsg({ - user: user2, - pinned: true, - pinned_at: new Date('2020-01-01T00:00:00.003Z'), - }); - const m2u2 = generateMsg({ - user: user2, - pinned: true, - pinned_at: new Date('2020-01-01T00:00:00.004Z'), - }); - - state.addPinnedMessages([m1u1, m2u1, m1u2, m2u2]); - - expect(state.pinnedMessages).to.have.length(4); - - const user1NewName = uuidv4(); - user1 = { - ...user1, - name: user1NewName, - }; - - state.updateUserMessages(user1); - - const byId = (id) => state.pinnedMessages.find((m) => m.id === id); - - expect(state.pinnedMessages).to.have.length(4); - - expect(byId(m1u1.id).user.name).to.be.equal(user1NewName); - expect(byId(m2u1.id).user.name).to.be.equal(user1NewName); - - expect(byId(m1u2.id).user.name).to.be.equal(user2.name); - expect(byId(m2u2.id).user.name).to.be.equal(user2.name); - }); -}); - describe('ChannelState members store', () => { it('initializes members store with an empty members map', () => { const state = new ChannelState(); diff --git a/test/unit/client.test.js b/test/unit/client.test.js index 72e093193..7de0fabc1 100644 --- a/test/unit/client.test.js +++ b/test/unit/client.test.js @@ -1533,201 +1533,157 @@ describe('message deletion', () => { }); }); -describe('user.messages.deleted', () => { +// Regression coverage for GetStream/stream-chat-js#1736. +// Hard-deleting a user whose cached messages include a self-quote (a message that +// quotes another message from the same user) used to throw inside the message-deletion +// path, aborting the entire dispatchEvent chain — downstream listeners and offline-DB +// writes silently dropped. These tests exercise both event entry points that funnel into +// _deleteUserMessageReference. +describe('user.updated propagates to message + pinned paginators', () => { let client; beforeEach(async () => { client = await getClientWithUser(); }); - const bannedUser = { id: 'banned-user' }; - const otherUser = { id: 'other-user' }; - const messageSet1 = [ - { - attachments: [ - { - type: 'image', - title: 'YouTube', - title_link: 'https://www.youtube.com/', - text: 'Enjoy the videos and music you love, upload original content, and share it all with friends, family, and the world on YouTube.', - image_url: 'https://www.youtube.com/img/desktop/yt_1200.png', - thumb_url: 'https://www.youtube.com/img/desktop/yt_1200.png', - og_scrape_url: 'https://www.youtube.com/', - }, - ], - created_at: '2021-01-01T00:01:00', - pinned: true, - pinned_at: '2022-01-01T00:01:00', - user: bannedUser, - }, - { - created_at: '2021-01-01T00:02:00', - pinned: true, - pinned_at: '2022-01-01T00:02:00', - user: otherUser, - }, - { created_at: '2021-01-01T00:03:00', user: bannedUser }, - ].map(generateMsg); - - const quoted_message = messageSet1[0]; - const messageSet2 = [ - { - created_at: '2020-01-01T00:01:00', + it('reflects the updated user on both messagePaginator and pinnedMessagesPaginator', () => { + const author = { id: 'author', name: 'Old Name' }; + const channel = client.channel('messaging', 'user-updated-1'); + const message = generateMsg({ id: 'm1', user: author }); + const pinned = generateMsg({ + id: 'p1', + cid: channel.cid, + user: author, pinned: true, - pinned_at: '2022-01-01T00:03:00', - user: bannedUser, - }, - { - created_at: '2020-01-01T00:02:00', - quoted_message, - quoted_message_id: quoted_message.id, - user: otherUser, - }, - { created_at: '2020-01-01T00:03:00', user: bannedUser }, - { created_at: '2020-01-01T00:04:00', user: otherUser }, - ].map(generateMsg); - - const parent_id = messageSet2[0].id; - const thread1 = [ - { created_at: '2020-01-01T00:01:30', parent_id, user: bannedUser, type: 'reply' }, - { created_at: '2020-01-01T00:02:35', parent_id, user: otherUser, type: 'reply' }, - { created_at: '2020-01-01T00:03:45', parent_id, user: bannedUser, type: 'reply' }, - { created_at: '2020-01-01T00:04:00', parent_id, user: otherUser, type: 'reply' }, - ]; + pinned_at: '2020-01-01T00:00:00.000Z', + }); + + // addMessagesSorted registers the user->channel reference that _updateUserMessageReferences walks. + channel.state.addMessagesSorted([message]); + channel.messagePaginator.setItems({ + valueOrFactory: [message], + isFirstPage: true, + isLastPage: true, + }); + channel.pinnedMessagesPaginator.ingestPage({ + page: [utils.formatMessage(pinned)], + isHead: true, + isTail: true, + setActive: true, + }); - const pinnedMessages = [messageSet1[0], messageSet1[1], messageSet2[0]]; + client._handleClientEvent({ + type: 'user.updated', + user: { ...author, name: 'New Name' }, + }); - // Thread-reply deletion is owned by the Thread object (covered in threads.test.ts); this suite - // exercises the pinned-message cache. addMessagesSorted still registers the user->channel - // reference (client.state.userChannelReferences) that the client-level deletion loop walks. - const setupChannel = (type, id) => { - const channel = client.channel(type, id); - channel.state.addMessagesSorted(messageSet1); - channel.state.addMessagesSorted(messageSet2, false, false, true, 'new'); + expect(channel.messagePaginator.getItem('m1')?.user?.name).toBe('New Name'); + expect(channel.pinnedMessagesPaginator.getItem('p1')?.user?.name).toBe('New Name'); + }); +}); - // pinned messages - channel.state.addPinnedMessages(pinnedMessages); +describe('user.messages.deleted (client-level, cross-channel)', () => { + let client; + const bannedUser = { id: 'banned-user' }; + const otherUser = { id: 'other-user' }; - expect(channel.state.pinnedMessages).toHaveLength(pinnedMessages.length); + beforeEach(async () => { + client = await getClientWithUser(); + }); + // Seeds a channel with one main + one pinned message from the banned user (plus a pinned message + // from another user), registering the user->channel reference the client-level loop walks. + const setupChannel = (id) => { + const channel = client.channel('messaging', id); + const main = generateMsg({ id: `${id}-m`, cid: channel.cid, user: bannedUser }); + const pinned = generateMsg({ + id: `${id}-p`, + cid: channel.cid, + user: bannedUser, + pinned: true, + pinned_at: '2020-01-01T00:00:00.000Z', + }); + const otherPinned = generateMsg({ + id: `${id}-op`, + cid: channel.cid, + user: otherUser, + pinned: true, + pinned_at: '2020-01-02T00:00:00.000Z', + }); + channel.state.addMessagesSorted([main]); + channel.messagePaginator.setItems({ + valueOrFactory: [main], + isFirstPage: true, + isLastPage: true, + }); + channel.pinnedMessagesPaginator.ingestPage({ + page: [pinned, otherPinned].map((m) => utils.formatMessage(m)), + isHead: true, + isTail: true, + setActive: true, + }); return channel; }; - it('ignores channel specific event', () => { - const channels = [setupChannel('type', 'id1'), setupChannel('type', 'id2')]; - const event = { + it('ignores a channel-scoped (cid-carrying) event — the channel owns it', () => { + const channel = setupChannel('c1'); + + client._handleClientEvent({ type: 'user.messages.deleted', - cid: channels[0].cid, - channel_type: channels[0].type, - channel_id: channels[0].id, + cid: channel.cid, user: bannedUser, hard_delete: true, - created_at: '2025-02-01T14:01:30.000Z', - }; - client._handleClientEvent(event); - - channels.forEach((channel) => { - const check = (message) => { - expect(message).toEqual(message); - }; - channel.state.pinnedMessages.forEach(check); + created_at: '2025-01-01T00:00:00.000Z', }); + + // cid present → the client-level cross-channel loop must be a no-op (no double-delete). + expect(channel.messagePaginator.items?.map((m) => m.id)).to.include('c1-m'); + expect(channel.pinnedMessagesPaginator.items?.map((m) => m.id)).to.include('c1-p'); }); - it('removes the messages on hard delete', () => { - const channels = [setupChannel('type', 'id1'), setupChannel('type', 'id2')]; + it("soft-deletes the user's main and pinned messages across channels", () => { + const channels = [setupChannel('c1'), setupChannel('c2')]; - const event = { + client._handleClientEvent({ type: 'user.messages.deleted', user: bannedUser, - hard_delete: true, - created_at: '2025-02-01T14:01:30.000Z', - }; - client._handleClientEvent(event); + soft_delete: true, + created_at: '2025-01-01T00:00:00.000Z', + }); + channels.forEach((channel) => { - const check = (message) => { - const deletedMessage = { - attachments: [], - cid: message.cid, - created_at: message.created_at, - deleted_at: new Date(event.created_at), - id: message.id, - latest_reactions: [], - mentioned_users: [], - own_reactions: [], - parent_id: message.parent_id, - reply_count: message.reply_count, - status: message.status, - thread_participants: message.thread_participants, - type: 'deleted', - updated_at: message.updated_at, - user: message.user, - }; - if (message.user.id === bannedUser.id) { - expect(message).toStrictEqual(deletedMessage); - } else if (message.quoted_message) { - expect(message).toStrictEqual({ - ...message, - quoted_message: { - ...deletedMessage, - id: message.quoted_message.id, - user: message.quoted_message.user, - created_at: message.quoted_message.created_at, - updated_at: message.quoted_message.updated_at, - }, - }); - } else { - expect(message).toEqual(message); - } - }; - channel.state.pinnedMessages.forEach(check); + const id = channel.id; + expect(channel.messagePaginator.getItem(`${id}-m`)?.type).to.equal('deleted'); + expect(channel.pinnedMessagesPaginator.getItem(`${id}-p`)?.type).to.equal( + 'deleted', + ); + // the other user's pinned message is untouched + expect(channel.pinnedMessagesPaginator.getItem(`${id}-op`)?.type).to.not.equal( + 'deleted', + ); }); }); - it('removes the messages on soft delete', () => { - const channels = [setupChannel('type', 'id1'), setupChannel('type', 'id2')]; + it("hard-deletes the user's main and pinned messages across channels", () => { + const channels = [setupChannel('c1'), setupChannel('c2')]; - const event = { + client._handleClientEvent({ type: 'user.messages.deleted', user: bannedUser, - soft_delete: true, - created_at: '2025-02-01T14:01:30.000Z', - }; - client._handleClientEvent(event); + hard_delete: true, + created_at: '2025-01-01T00:00:00.000Z', + }); + channels.forEach((channel) => { - const check = (message) => { - if (message.user.id === bannedUser.id) { - expect(message).toStrictEqual({ - ...message, - attachments: [], - deleted_at: new Date(event.created_at), - type: 'deleted', - }); - } else if (message.quoted_message) { - expect(message).toStrictEqual({ - ...message, - quoted_message: { - ...message.quoted_message, - attachments: [], - deleted_at: new Date(event.created_at), - type: 'deleted', - }, - }); - } else { - expect(message).toEqual(message); - } - }; - channel.state.pinnedMessages.forEach(check); + const id = channel.id; + expect(channel.messagePaginator.items?.map((m) => m.id)).to.not.include(`${id}-m`); + expect(channel.pinnedMessagesPaginator.items?.map((m) => m.id)).to.eql([ + `${id}-op`, + ]); }); }); }); -// Regression coverage for GetStream/stream-chat-js#1736. -// Hard-deleting a user whose cached messages include a self-quote (a message that -// quotes another message from the same user) used to throw inside -// _deleteUserMessages, aborting the entire dispatchEvent chain — downstream -// listeners and offline-DB writes silently dropped. These tests exercise both -// event entry points that funnel into _deleteUserMessageReference. describe('user.messages.deleted — quoted_message regression (#1736)', () => { let client; const bannedUser = { id: 'banned-user' }; diff --git a/test/unit/utils.test.ts b/test/unit/utils.test.ts index 2c45746f6..a5f3b854d 100644 --- a/test/unit/utils.test.ts +++ b/test/unit/utils.test.ts @@ -1,16 +1,13 @@ import sinon from 'sinon'; import { describe, beforeEach, afterEach, it, expect, vi } from 'vitest'; -import { generateMsg } from './test-utils/generateMessage'; import { generateChannel } from './test-utils/generateChannel'; import { generateMember } from './test-utils/generateMember'; import { generateUser } from './test-utils/generateUser'; import { getClientWithUser } from './test-utils/getClient'; -import { generateUUIDv4 as uuidv4 } from '../../src/utils'; import { getAndWatchChannel, - addToMessageList, findIndexInSortedArray, channelHasReadEvents, channelTracksReadLocally, @@ -31,147 +28,9 @@ import { sleep, } from '../../src/utils'; -import type { - ChannelFilters, - ChannelSortBase, - FormatMessageResponse, - MessageResponse, -} from '../../src'; +import type { ChannelFilters, ChannelSortBase, MessageResponse } from '../../src'; import { StreamChat, Channel } from '../../src'; -describe('addToMessageList', () => { - const timestamp = new Date('2024-09-18T15:30:00.000Z').getTime(); - // messages with each created_at 10 seconds apart - let messagesBefore: FormatMessageResponse[]; - - const getNewFormattedMessage = ({ - timeOffset, - id = uuidv4(), - }: { - timeOffset: number; - id?: string; - }) => - formatMessage( - generateMsg({ - id, - created_at: new Date(timestamp + timeOffset), - }) as MessageResponse, - ); - - beforeEach(() => { - messagesBefore = Array.from({ length: 5 }, (_, index) => - formatMessage( - generateMsg({ - created_at: new Date(timestamp + index * 10 * 1000), - }) as MessageResponse, - ), - ); - }); - - it('new message is inserted at the correct index', () => { - const newMessage = getNewFormattedMessage({ timeOffset: 25 * 1000 }); - - const messagesAfter = addToMessageList(messagesBefore, newMessage); - - expect(messagesAfter).to.not.equal(messagesBefore); - expect(messagesAfter).to.have.length(6); - expect(messagesAfter).to.contain(newMessage); - expect(messagesAfter[3]).to.equal(newMessage); - }); - - it('replaces the message which created_at changed to a server response created_at', () => { - const newMessage = getNewFormattedMessage({ - timeOffset: 33 * 1000, - id: messagesBefore[2].id, - }); - - expect(newMessage.id).to.equal(messagesBefore[2].id); - - const messagesAfter = addToMessageList(messagesBefore, newMessage, true); - - expect(messagesAfter).to.not.equal(messagesBefore); - expect(messagesAfter).to.have.length(5); - expect(messagesAfter).to.contain(newMessage); - expect(messagesAfter[3]).to.equal(newMessage); - }); - - it('adds a new message to an empty message list', () => { - const newMessage = getNewFormattedMessage({ timeOffset: 0 }); - - const emptyMessagesBefore = []; - - const messagesAfter = addToMessageList(emptyMessagesBefore, newMessage); - - expect(messagesAfter).to.have.length(1); - expect(messagesAfter).to.contain(newMessage); - }); - - it("doesn't add a new message to an empty message list if timestampChanged & addIfDoesNotExist are false", () => { - const newMessage = getNewFormattedMessage({ timeOffset: 0 }); - - const emptyMessagesBefore = []; - - const messagesAfter = addToMessageList( - emptyMessagesBefore, - newMessage, - false, - 'created_at', - false, - ); - - expect(messagesAfter).to.have.length(0); - }); - - it("adds message to the end of the list if it's the newest one", () => { - const newMessage = getNewFormattedMessage({ timeOffset: 50 * 1000 }); - - const messagesAfter = addToMessageList(messagesBefore, newMessage); - - expect(messagesAfter).to.have.length(6); - expect(messagesAfter).to.contain(newMessage); - expect(messagesAfter.at(-1)).to.equal(newMessage); - }); - - it("doesn't add a newest message to a message list if timestampChanged & addIfDoesNotExist are false", () => { - const newMessage = getNewFormattedMessage({ timeOffset: 50 * 1000 }); - - const messagesAfter = addToMessageList( - messagesBefore, - newMessage, - false, - 'created_at', - false, - ); - - expect(messagesAfter).to.have.length(5); - // FIXME: it'd be nice if the function returned old - // unchanged array in case of no modification such as this one - expect(messagesAfter).to.deep.equal(messagesBefore); - }); - - it("updates an existing message that wasn't filtered due to changed timestamp (timestampChanged)", () => { - const newMessage = getNewFormattedMessage({ - timeOffset: 30 * 1000, - id: messagesBefore[4].id, - }); - - expect(messagesBefore[4].id).to.equal(newMessage.id); - expect(messagesBefore[4].text).to.not.equal(newMessage.text); - expect(messagesBefore[4]).to.not.equal(newMessage); - - const messagesAfter = addToMessageList( - messagesBefore, - newMessage, - false, - 'created_at', - false, - ); - - expect(messagesAfter).to.have.length(5); - expect(messagesAfter[4]).to.equal(newMessage); - }); -}); - describe('findIndexInSortedArray', () => { it('finds index in the middle of haystack (asc)', () => { const needle = 5; From 8361a08a675311ad1bf72a70070d8fc996a9bed8 Mon Sep 17 00:00:00 2001 From: martincupela Date: Tue, 21 Jul 2026 11:06:43 +0200 Subject: [PATCH 15/25] =?UTF-8?q?docs(spec):=20mark=20Task=2018=20done=20(?= =?UTF-8?q?pinned=20messages=20=E2=86=92=20PinnedMessagePaginator)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit channel.state.pinnedMessages removed; PinnedMessagePaginator (unread-free MessageIntervalPaginator subclass) is the source of truth, fed by channel/client WS-event handlers. React reads it via usePinnedMessagesCount. Both suites green. Co-Authored-By: Claude Opus 4.8 --- specs/remove-legacy-channelstate-messages/plan.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/specs/remove-legacy-channelstate-messages/plan.md b/specs/remove-legacy-channelstate-messages/plan.md index f350a00a2..9928f47bd 100644 --- a/specs/remove-legacy-channelstate-messages/plan.md +++ b/specs/remove-legacy-channelstate-messages/plan.md @@ -503,7 +503,20 @@ pagination is a **gated, breaking** enhancement requiring RN/mobile coordination ## Task 18 (FOLLOW-ON): `channel.state.pinnedMessages` → `channel.pinnedMessagesPaginator` **File(s):** `src/pagination/paginators/PinnedMessagePaginator.ts` (new), `src/channel.ts`, `src/channel_state.ts`, -`src/client.ts`, stream-chat-react pinned views. **Dependencies:** Task 11. **Status:** planned. **Owner:** unassigned +`src/client.ts`, stream-chat-react pinned views. **Dependencies:** Task 11. **Owner:** claude +**Status:** DONE (uncommitted final step — pending review). Landed in sub-steps, each verified green: +(1) extract unread-free `MessageIntervalPaginator` base [committed `ca61f933`]; (2) `PinnedMessagePaginator` +extends it [committed `ecd84fa5`]; (3) wire `channel.pinnedMessagesPaginator` from events in parallel with +the legacy store [committed `031c5cac`]; (4a) React `usePinnedMessagesCount` reads the paginator +[committed React `2badfbb2d`]; (4b) delete `channel.state.pinnedMessages` + `addPinnedMessage(s)`/ +`removePinnedMessage` + the pinned-only methods (`addReaction`/`removeReaction`/`_updateMessage`/ +`updateUserMessages`/`deleteUserMessages`/`_addToMessageList`/`removeMessageFromArray`/`clearMessages`) + +the orphaned `utils.addToMessageList`/`utils.deleteUserMessages` helpers. Per-user pinned updates re-homed +to `pinnedMessagesPaginator.reflectUserUpdate`/`applyMessageDeletionForUser`. JS 2573 + React 2513 green. +The unread carve-out was achieved by the base-class extraction (unread absent by construction), not +suppression. **Follow-on still open:** thread replies + pinned both extend the clean base now, but +`MessageReplyPaginator` (unused) reconciliation and the final `addMessagesSorted` deletion remain +(post-Task-13, per the addMessagesSorted-removal note). **Why a dedicated paginator, not a bare `MessagePaginator` + filters:** pinned messages use a _different endpoint_ (`channel.getPinnedMessages` → `/pinned_messages`) with `PinnedMessagePaginationOptions` (id-based From 5c3ca21ca098234acce8165e28c0e001b8cf7ffc Mon Sep 17 00:00:00 2001 From: martincupela Date: Tue, 21 Jul 2026 11:33:47 +0200 Subject: [PATCH 16/25] test: drop remaining stale channel.state reads from the suite (Task 13 audit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test-types message reply generator read channel.state.messages (removed) for the thread parent — use response.message.id directly (the reply's parent_id). A channel last_message_at test passed a stale 'new' positional arg (old message-set type) to addMessagesSorted — removed. Co-Authored-By: Claude Opus 4.8 --- test/typescript/response-generators/message.js | 5 ++--- test/unit/channel.test.js | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/test/typescript/response-generators/message.js b/test/typescript/response-generators/message.js index 1be1cbd7b..8049ccecb 100644 --- a/test/typescript/response-generators/message.js +++ b/test/typescript/response-generators/message.js @@ -73,9 +73,8 @@ async function getReplies() { parent_id: response.message.id, }); await channel.query(); - const parent = channel.state.messages[channel.state.messages.length - 1]; - - return await channel.getReplies(parent.id); + // The second sendMessage above is a reply to response.message, so that is the thread parent. + return await channel.getReplies(response.message.id); } async function sendAction() { diff --git a/test/unit/channel.test.js b/test/unit/channel.test.js index cdb94f3ef..c5b1c7cc5 100644 --- a/test/unit/channel.test.js +++ b/test/unit/channel.test.js @@ -2667,7 +2667,7 @@ describe('Channel lastMessage', async () => { generateMsg({ date: '2017-11-21T00:05:35' }), ]; channel.state.addMessagesSorted(latestMessages); - channel.state.addMessagesSorted(otherMessages, 'new'); + channel.state.addMessagesSorted(otherMessages); expect(channel.state.last_message_at.getTime()).toBe( new Date(latestMessages[1].created_at).getTime(), From 3b52134597aa2f320a59abd1451094b5a8558bf2 Mon Sep 17 00:00:00 2001 From: martincupela Date: Tue, 21 Jul 2026 11:34:07 +0200 Subject: [PATCH 17/25] docs(spec): mark Task 13 done; refresh stale Task 11/12 statuses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 13 (test close-out audit) complete — only two stale channel.state references remained, now fixed; parity suites confirmed. Correct Task 11/12 statuses to committed. Co-Authored-By: Claude Opus 4.8 --- .../plan.md | 78 ++++++++++++++++--- 1 file changed, 69 insertions(+), 9 deletions(-) diff --git a/specs/remove-legacy-channelstate-messages/plan.md b/specs/remove-legacy-channelstate-messages/plan.md index 9928f47bd..cd07648c2 100644 --- a/specs/remove-legacy-channelstate-messages/plan.md +++ b/specs/remove-legacy-channelstate-messages/plan.md @@ -280,11 +280,11 @@ dual-writing → delete the store → types/exports → tests. **Dependencies:** Task 9, Task 10 (and Task 15 if offline in scope) -**Status:** in progress — reaction sub-steps 1-2 done; **step 3a DONE** (main message-list -storage removed); **step 3b DONE** (`channel.state.threads` removed, thread-only methods deleted, -thread reply `own_reactions` + user-deletion re-homed onto the `Thread` object; both suites green). -`addMessagesSorted`/`addMessageSorted` remain as a slim channel-meta path (`last_message_at` + -user-reference map) for full deletion after Task 13. +**Status:** DONE (committed). Reaction sub-steps 1-2 done; **step 3a** (main message-list storage +removed) committed `caba066b`/`c238323f`; **step 3b** (`channel.state.threads` removed, thread-only +methods deleted, thread reply `own_reactions` + user-deletion re-homed onto the `Thread` object) +committed `b9c4ed61`/`be4ac9dc`; both suites green. `addMessagesSorted`/`addMessageSorted` remain as a +slim channel-meta path (`last_message_at` + user-reference map) for full deletion after Task 13. **Owner:** claude @@ -373,7 +373,8 @@ paginators (owner-approved). **Dependencies:** Task 11 -**Status:** DONE (uncommitted — pending review). Both suites green, `yarn types` + `yarn lint` clean. +**Status:** DONE (committed `fea2407b` src + `8114a2cf` spec). Both suites green, `yarn types` + +`yarn lint` clean. Carries the `BREAKING CHANGE:` footer for the whole ChannelState storage removal. **Owner:** claude @@ -407,9 +408,18 @@ paginators (owner-approved). **Dependencies:** Task 11, Task 12 -**Status:** pending +**Status:** DONE (uncommitted — pending review). Most of the surgery landed incrementally during +Tasks 11/12/18 (message-set/thread/pinned suites removed or re-scoped to the paginators; +`channel_state`/`channel`/`client`/`utils` suites rewritten; `channel.ts _handleChannelEvent` +reorganized one-describe-per-WS-event; parity coverage added for paginators + pinned + user-deletion/ +update wiring). Close-out audit swept all of `test/` and found only two stale references, both fixed: +`test/typescript/response-generators/message.js` read `channel.state.messages` (→ use +`response.message.id` for the thread parent), and a `channel.test.js` `last_message_at` test passed a +stale `'new'` positional arg to `addMessagesSorted` (removed). Parity suites confirmed permanent: +`CooldownTimer.test.ts` (paginator `ingestPage`), `messageDelivery/*` (`latestItems`), plus the +paginator/channel/client/thread suites. `yarn test-unit` (2583) + `yarn types` + `yarn lint` green. -**Owner:** unassigned +**Owner:** claude **Scope:** @@ -419,7 +429,10 @@ paginators (owner-approved). **Acceptance Criteria:** -- [ ] `yarn test-unit` green; no test references `messageSets`/`addMessageSorted`/`state.messages`. +- [x] `yarn test-unit` green; no test references removed state (`messageSets`/`state.messages`/ + `state.threads`/`state.pinnedMessages`/`addToMessageList`). NOTE: `addMessagesSorted`/`addMessageSorted` + survive as the channel-meta path and are still legitimately referenced; their full removal (and the + remaining vestigial test seeds) is the deferred post-Task-13 cleanup. --- @@ -677,6 +690,53 @@ frequently (channels). Land per-paginator with parity tests rather than all at o --- +## Task 21 (INTEGRATION): land stream-chat-react PR #3245 "fix: unread indicators V10" + +**Repo:** stream-chat-react. **PR:** https://github.com/GetStream/stream-chat-react/pull/3245 +(base `feat/message-paginator-master-merge`, head `fix/unread-indicators`, by @isekovanic; goes with +LLC stream-chat-js PR #1803). **Dependencies:** none blocking — verified compatible with this +initiative. **Status:** planned. **Owner:** unassigned + +**What it is:** the React counterpart of the paginator unread model. Wires the "Unread messages" +separator / "N new" banner / focus-scroll entirely to `channel.messagePaginator`. Files (+93/-34): +`Channel.tsx` (seed unread snapshot on cached-channel reopen, before markRead), `MessageList.tsx` + +`VirtualizedMessageList.tsx` (focus-signal token lifecycle: `scheduleMessageFocusSignalClear` after +scroll, `clearMessageFocusSignal` on unmount), `UnreadMessagesNotification.tsx` (`clearUnreadSnapshot` +on mark-read), `useMarkRead.ts` (the bulk: `setViewingLive` gating, `resetUnreadSnapshot` on genuine +catch-up vs. persist-on-open, scrolled-back-to-bottom tracking via refs). + +**Compatibility with our LLC changes: verified clean.** Every API it uses survives our work — unread +surface (`seedUnreadSnapshot`, `unreadStateSnapshot`, `setViewingLive`/`isViewingLive`, +`clearUnreadSnapshot`) is retained on `MessagePaginator` through the Task-18 base extraction; focus +signal (`messageFocusSignal`, `scheduleMessageFocusSignalClear`, `clearMessageFocusSignal`) lives on +the `MessageIntervalPaginator` base and is inherited. It reads NO removed state (no +`channel.state.messages`/`threads`/`pinnedMessages`), only `channel.state.read` + `channel.messagePaginator.*`. +It is design-consistent: manually seeds on cached reopen precisely because our auto-seed only fires on +a first-page query, and drives `setViewingLive` to feed the exact gate our `message.new` handler reads. + +**Scope to land:** + +- **Add the missing tests (gating condition).** The PR ships 5 `src/` files with ZERO test changes for + an intricate `useMarkRead` state machine (viewing-live gate, catch-up-vs-open snapshot handling, + scrolled-back-to-bottom refs). Add unit tests: open/reopen keeps the separator where the user left + off; genuine scroll-back-to-bottom clears the snapshot; `visibilitychange` + at-bottom + no + `hasMoreNewer` toggles `setViewingLive`; focus-signal is cleared after scroll and on unmount. +- **Reconcile with our React branch.** Both this PR and our `checkpoint/channelstate-removal-wip` + React branch touch `Channel.tsx` — DIFFERENT hunks (its reopen `seedUnreadSnapshot` vs. our + `user.deleted` `channel.state.messages`→`channel.messagePaginator.items` fix), so a trivial, + no-semantic-conflict merge. The PR does not touch our other migrated readers. +- **Sequencing.** Both are parallel off `feat/message-paginator-master-merge`; whichever lands second + takes the trivial `Channel.tsx` merge. Runtime correctness needs the LLC unread APIs shipped — they + are in our branch, so no extra LLC work is required beyond what this initiative already lands. + +**Acceptance Criteria:** + +- [ ] PR #3245 integrated onto our React branch (cherry-pick/merge), `Channel.tsx` reconciled. +- [ ] `useMarkRead` unread state-machine unit tests added and green. +- [ ] React `yarn types` + `yarn lint` + `yarn test` green against our stream-chat-js dist. + +--- + ## Execution Order ``` From 7d79352abc2d34cb4449bb18de89b7dc39871a9d Mon Sep 17 00:00:00 2001 From: martincupela Date: Tue, 21 Jul 2026 14:44:15 +0200 Subject: [PATCH 18/25] refactor(channel): derive last_message_at from paginator; drop isUpToDate and _trackLatestMessage Continue the ChannelState message-storage removal: the message paginator is the single source of truth for the channel's latest-message metadata, and several legacy ChannelState surfaces are gone. - MessageIntervalPaginator now tracks the latest message (latestMessageId + a latestMessage getter), advanced monotonically on ingest and mirrored into paginator state via a preprocessor. All message paginators auto-track on ingest, so it is robust to the active window and to interval/item sort orientation (the reply list backing ThreadListItemUI included). - channel.state.last_message_at is now a read-only getter derived from messagePaginator.latestMessage; the writable setter and its backing field are removed. - Remove channel.state.isUpToDate / setIsUpToDate. Not disrupting a scrolled-away view on a live message is handled structurally by the paginator's interval routing, not a flag. - Remove Channel._trackLatestMessage. user.updated / user.deleted propagation now scans active channels (reflectUserUpdate / applyMessageDeletionForUser already self-filter by author id); a targeted user-reference index is planned in specs/user-reference-index. - Remove ChannelState.addMessagesSorted / addMessageSorted and their last remaining callers. - Offline pending-message replay tracks the latest via messagePaginator.trackLatestMessage. - Docs: docs/breaking-changes-v14-v15.md. BREAKING CHANGE: channel.state.last_message_at is read-only (derived from the message paginator). channel.state.isUpToDate / setIsUpToDate are removed (use messagePaginator.isActiveIntervalAtHead, hasMoreHead, jumpToTheLatestMessage). ChannelState.addMessagesSorted / addMessageSorted are removed. See docs/breaking-changes-v14-v15.md for migration. Co-Authored-By: Claude Opus 4.8 --- docs/breaking-changes-v14-v15.md | 55 +++++ specs/user-reference-index/decisions.md | 56 +++++ specs/user-reference-index/goal.md | 62 ++++++ specs/user-reference-index/plan.md | 158 ++++++++++++++ specs/user-reference-index/state.json | 25 +++ src/channel.ts | 21 +- src/channel_state.ts | 128 ++--------- src/client.ts | 18 +- src/offline-support/offline_support_api.ts | 3 +- .../paginators/MessageIntervalPaginator.ts | 124 ++++++++++- src/pagination/paginators/MessagePaginator.ts | 22 ++ test/unit/channel.test.js | 64 +++++- test/unit/channel_state.test.js | 61 ------ test/unit/client.test.js | 15 +- .../offline_support_api.test.ts | 9 +- .../paginators/ChannelPaginator.test.ts | 46 ++-- .../paginators/MessagePaginator.test.ts | 202 +++++++++++++++++- test/unit/poll_manager.test.ts | 1 - 18 files changed, 827 insertions(+), 243 deletions(-) create mode 100644 docs/breaking-changes-v14-v15.md create mode 100644 specs/user-reference-index/decisions.md create mode 100644 specs/user-reference-index/goal.md create mode 100644 specs/user-reference-index/plan.md create mode 100644 specs/user-reference-index/state.json diff --git a/docs/breaking-changes-v14-v15.md b/docs/breaking-changes-v14-v15.md new file mode 100644 index 000000000..db19df652 --- /dev/null +++ b/docs/breaking-changes-v14-v15.md @@ -0,0 +1,55 @@ +# Breaking Changes: v14 → v15 (stream-chat JS) + +Consumer-facing **breaking changes** introduced in the v15 line of the JS SDK on +`feat/message-paginator-master-merge` (beyond the master ⇄ PR merge itself). This is the source for +the v14 → v15 migration guide / release notes. Append newest at the top. Keep each entry +self-contained: what changed, before → after, how to migrate, why. + +> Scope note: this file tracks **public API / observable behavior** changes. Internal refactors with +> identical output belong in `decisions.md`, not here. + +--- + +## `ChannelState.last_message_at` is now read-only (derived) + +**Area:** `ChannelState` · **Status:** implemented + +`channel.state.last_message_at` is now a **read-only getter** derived from the message paginator's +tracked latest message (`channel.messagePaginator.latestMessage?.created_at ?? null`). The writable +setter and its backing field were removed. + +- **Before:** `channel.state.last_message_at = someDate` (writable). Internally maintained by the + now-removed `Channel._trackLatestMessage`. +- **After:** read-only. It reflects whatever the message paginator holds as its latest message. +- **Migrate:** don't assign it. To make a channel's `last_message_at` reflect a message, ingest / + track that message on `channel.messagePaginator` (the message source of truth). Assigning to it is + a no-op (silently ignored / throws in strict mode) and a TypeScript error. +- **Why:** the message paginator is the single source of truth for messages; `last_message_at` is a + projection of it. A separate writable field could drift from the actual latest message. + +## `ChannelState.isUpToDate` / `setIsUpToDate` removed + +**Area:** `ChannelState` · **Status:** implemented + +The `isUpToDate` flag and its `setIsUpToDate(boolean)` setter were removed. They gated whether an +incoming `message.new` was appended to the visible message list when the user had scrolled to older +history. + +- **Before:** UI SDKs set `channel.state.setIsUpToDate(false)` when jumping to an older window so live + messages were not forced onto the visible list, and read `channel.state.isUpToDate` to decide + whether to show a "jump to latest" affordance. +- **After:** neither exists. Message routing is handled structurally by the message paginator: a live + message newer than the loaded head lands in the head (or logical-head) interval, which is not the + active window when the viewer has jumped away, so the visible window is preserved with no flag. +- **Migrate:** + - "Am I viewing the newest window?" → `channel.messagePaginator.isActiveIntervalAtHead` (getter). + - "Are there newer messages not yet loaded?" → `channel.messagePaginator.hasMoreHead` (reactive + via `channel.messagePaginator.state`). + - "Jump to the latest" → `channel.messagePaginator.jumpToTheLatestMessage()`. +- **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 is a channel-level fact, + independent of what the UI is currently viewing. +- **Why:** the flag duplicated state the paginator already models, and was only ever set `false` on + disconnect on this branch — its message-list responsibility had already moved to the paginator. + +--- diff --git a/specs/user-reference-index/decisions.md b/specs/user-reference-index/decisions.md new file mode 100644 index 000000000..30ad9f22b --- /dev/null +++ b/specs/user-reference-index/decisions.md @@ -0,0 +1,56 @@ +# User Reference Index — Decisions + +Open scope/design decisions. Resolve **D1** and **D2** before implementation (they set the shape the +tasks build against). + +## D1 — Where does the index live? (blocks Task 2+) + +- **Option A — per-`ItemIndex`:** each paginator's `ItemIndex` maintains a `userId → Set` + map as items are `setOne`/`remove`d. The client asks each active channel's paginators "do you + reference this user?" — still iterates channels, but each lookup is O(1) instead of O(items). + Cheapest to keep consistent (single choke point: `ItemIndex.setOne`/`remove`), but still O(active + channels) to find affected ones. +- **Option B — client-level aggregate:** a `userId → Map>` on `client.state`, + updated as paginators ingest/remove. `user.updated` looks up the exact channels + messages. Fully + targeted (no channel iteration), but requires paginators to report ref changes up to the client + (a subscription or callback), which reintroduces some coupling. +- **Recommendation:** start with **A** (contained, single choke point) and measure; escalate to **B** + only if the O(active channels) lookup is still a hotspot. Decide up front so tasks target one shape. + +## D2 — Author only, or author + quoted author? (blocks Task 3/4) + +The update path (`reflectUserUpdate`) only touches `message.user`. The delete path +(`applyMessageDeletionForUser`) touches `message.user` **and** `message.quoted_message.user`. Options: + +- Index **both** relationships under the same user key (a message referenced twice — as author and as + quoted author of another message — is fine; lookups dedupe by message id). +- Index **author only**, and keep a separate targeted pass for quoted authors (or accept a scan just + for the quoted case). +- **Recommendation:** index both; it is the only way to make the delete path fully targeted, and the + maintenance choke point already sees the whole message (so `quoted_message.user?.id` is available). + +## D3 — Index granularity: channel-set vs message-set + +- `userId → Set`: enough to call the existing `reflectUserUpdate(user)` / `applyMessageDeletionForUser({userId})` + on only the right channels (those methods still self-filter internally, but over a much smaller set). + Minimal change to the paginator methods. +- `userId → message ids`: lets the paginator update exactly the referenced messages (no per-channel + re-scan at all), but requires new paginator entry points that take explicit ids. +- **Recommendation:** channel-set first (smallest delta, reuses existing methods); revisit message-set + if profiling shows the in-channel self-filter is still significant. + +## D4 — Consistency on author replacement + +`user.id` does not change on `user.updated` (name/image only), so the index key is stable for updates. +But `ItemIndex.setOne` can replace a cached message with a **different author** (e.g. an edit event +carrying a corrected `user`, or an optimistic→confirmed swap). The maintenance logic must, on +`setOne`, diff the previous cached item's `user.id` / `quoted_message.user.id` against the new one and +move the reference. `remove`, `truncate`, and `clearStateAndCache` must drop references. This is the +main correctness surface — Task 2's tests must cover it. + +## D5 — Interaction with `userChannelReferences` + +Leave `client.state.userChannelReferences` (members/watchers/read) as-is. The new index is message- +scoped and separate. Do **not** try to merge them — they have different maintenance points and +lifetimes. Confirm no code path expects message authors to appear in `userChannelReferences` after +this change (the message-paginator-master-merge work already stopped registering them there). diff --git a/specs/user-reference-index/goal.md b/specs/user-reference-index/goal.md new file mode 100644 index 000000000..506d0caab --- /dev/null +++ b/specs/user-reference-index/goal.md @@ -0,0 +1,62 @@ +# User Reference Index — Goal + +## Background + +`Channel._trackLatestMessage` was removed (see `specs/message-paginator-master-merge`). It used to +register each message author into `client.state.userChannelReferences` (a `userId → { cid: true }` +map). With that registration gone, `user.updated` / `user.deleted` propagation to message content can +no longer rely on the map, so the client now **scans every active channel**: + +```ts +// client._updateUserMessageReferences / _deleteUserMessageReference +for (const channel of Object.values(this.activeChannels)) { + channel.pinnedMessagesPaginator.reflectUserUpdate(user); + channel.messagePaginator.reflectUserUpdate(user); // iterates the whole item index, filters by author id +} +``` + +`reflectUserUpdate` / `applyMessageDeletionForUser` are author-id-filtered no-ops on channels that +don't reference the user, so this is **correct** but does **O(active channels × loaded items)** work on +every `user.updated` / `user.deleted` — most of it wasted (a name change on one user walks every +message of every open channel). + +## Objective + +Introduce a **user → message-reference index** so that `user.updated` / `user.deleted` propagation +touches only the channels (ideally only the messages) that actually reference the user, restoring the +targeted behavior the old `userChannelReferences` author registration gave us — but maintained +automatically by the message stores rather than by an imperative per-message call in `channel.ts`. + +The index must cover **both** relationships the current handlers act on: + +- `message.user` (author) — used by `reflectUserUpdate` and `applyMessageDeletionForUser`. +- `message.quoted_message.user` (quoted author) — used by `applyMessageDeletionForUser` and the + quoted-message deletion path. + +## Success criteria + +- `user.updated` / `user.deleted` propagation visits only channels/messages that reference the user; + no full scan of unrelated active channels. +- Behavior parity with the current scan-based implementation: the same messages end up updated / + deleted (author and quoted-author), across `messagePaginator` and `pinnedMessagesPaginator`. +- The index is maintained automatically as messages are ingested / updated / removed / truncated / + cleared — no reintroduction of a per-message call in `channel.ts` (keep the channel↔paginator + layering clean). +- `yarn types`, `yarn lint` (0 warnings), `yarn test` all pass; new unit tests cover index + maintenance and targeted propagation; a benchmark or complexity note demonstrates the reduction. + +## Constraints + +- Build on branch `feat/message-paginator-master-merge` (this depends on the `_trackLatestMessage` + removal and the active-channel-scan handlers). +- Do not change the observable semantics of `user.updated` / `user.deleted` handling. +- Keep `client.state.userChannelReferences` for members / watchers / read references — this spec is + about **message** references only. +- Work in a dedicated worktree; do not push to remote. + +## Non-goals + +- Changing which stores participate (thread reply paginators are still handled by `Thread`'s own + subscriptions, not by the client-level message-reference propagation — unchanged here). +- Member / watcher / read reference handling. +- Any change to `last_message_at` / latest-message tracking (separate, already shipped). diff --git a/specs/user-reference-index/plan.md b/specs/user-reference-index/plan.md new file mode 100644 index 000000000..d574f491d --- /dev/null +++ b/specs/user-reference-index/plan.md @@ -0,0 +1,158 @@ +# Plan — User Reference Index + +See [`goal.md`](goal.md) for objective and success criteria; [`decisions.md`](decisions.md) for the +open design decisions (**D1** index location and **D2** author-vs-quoted must be resolved before +Task 2). + +## Worktree + +**Worktree path (JS SDK):** `../stream-chat-js-worktrees/user-reference-index` +**Branch:** `feat/user-reference-index` +**Base branch:** `feat/message-paginator-master-merge` + +All work MUST happen in this worktree, not the main checkout. Create/sync via the worktrees skill. +This feature has no React-side tasks (the change is internal to the JS SDK's event propagation). + +## Task overview + +Tasks are self-contained. The **critical path** is: resolve design (D1/D2) → build the index + +maintenance at the single choke point → expose lookup → rewrite the two client handlers → tests. The +index-maintenance task owns `ItemIndex` (a serialization chokepoint), so anything touching it chains +behind Task 2. + +--- + +## Task 1: Resolve design decisions (D1, D2, D3) + +**File(s) to create/modify:** `specs/user-reference-index/decisions.md` + +**Dependencies:** None + +**Status:** pending + +**Owner:** unassigned + +**Scope:** + +- Pick index location (D1: per-`ItemIndex` vs client-aggregate), relationship coverage (D2: author + + quoted author), and granularity (D3: channel-set vs message-set). Record the choice + rationale. +- Write the concrete type the rest of the tasks target, e.g. `UserReferenceIndex` with + `add(message)`, `remove(message)`, `channelsFor(userId)` / `messageIdsFor(userId)`. + +**Acceptance Criteria:** + +- [ ] D1, D2, D3 marked resolved with a one-line rationale each. +- [ ] The chosen index interface is written down (signatures) for Tasks 2–4 to implement against. + +## Task 2: Index + maintenance at the ingest/remove choke point + +**File(s) to create/modify:** `src/pagination/ItemIndex.ts` (+ a new `UserReferenceIndex` if D1=per-index), unit test alongside + +**Dependencies:** Task 1 + +**Status:** pending + +**Owner:** unassigned + +**Scope:** + +- Maintain the index from the single choke point that all ingestion funnels through + (`ItemIndex.setOne` / `remove`). On `setOne`, diff the previous cached item's referenced user id(s) + against the new item's and move references (D4). On `remove`, drop them. +- Cover both `message.user?.id` and `message.quoted_message?.user?.id` (per D2). +- Ensure `truncate` (bulk `remove`) and `clearStateAndCache` (index `clear`) drop references. + +**Acceptance Criteria:** + +- [ ] Index reflects author + quoted-author references after `setOne`/`remove`/`clear`. +- [ ] Author-replacement on `setOne` moves the reference (old id no longer maps, new id does). +- [ ] Unit tests for add/replace/remove/clear/truncate maintenance. + +## Task 3: Expose targeted lookup from Channel/paginators + +**File(s) to create/modify:** `src/pagination/paginators/BasePaginator.ts` or `MessageIntervalPaginator.ts` (lookup accessor), `src/channel.ts` (aggregate if needed) + +**Dependencies:** Task 2 + +**Status:** pending + +**Owner:** unassigned + +**Scope:** + +- Expose a way for the client to ask "which channels / messages reference `userId`?" per the chosen + shape (D1/D3). If D1=client-aggregate, wire paginator ref changes up to `client.state`; if + D1=per-index, expose `referencesUser(userId)` / `messageIdsForUser(userId)` on the paginator. + +**Acceptance Criteria:** + +- [ ] Client can resolve affected channels (and message ids if D3=message-set) in better than + O(loaded items) per channel. +- [ ] No new imperative maintenance call added to `channel.ts` event handlers. + +## Task 4: Rewrite client user-event handlers to use the index + +**File(s) to create/modify:** `src/client.ts` (`_updateUserMessageReferences`, `_deleteUserMessageReference`) + +**Dependencies:** Task 3 + +**Status:** pending + +**Owner:** unassigned + +**Scope:** + +- Replace the `Object.values(this.activeChannels)` scan with an index lookup that yields only the + channels (and messages, if D3=message-set) referencing the user, then apply `reflectUserUpdate` / + `applyMessageDeletionForUser` to those only. Keep the quoted-author deletion behavior identical. + +**Acceptance Criteria:** + +- [ ] `user.updated` updates exactly the previously-affected messages (author) — no others visited. +- [ ] `user.deleted` (soft + hard) affects author and quoted-author messages identically to today. +- [ ] Existing `client.test.js` user-event suites pass unchanged (behavior parity). + +## Task 5: Tests + complexity verification + +**File(s) to create/modify:** `test/unit/client.test.js`, `test/unit/pagination/*` as needed + +**Dependencies:** Task 4 + +**Status:** pending + +**Owner:** unassigned + +**Scope:** + +- Add tests: multi-channel setup where only some channels reference the user; assert only those are + touched (spy on `reflectUserUpdate` / `applyMessageDeletionForUser`, or on index lookup). +- Add a note / micro-benchmark demonstrating the visited-set is proportional to references, not to + total active channels × loaded items. + +**Acceptance Criteria:** + +- [ ] Test proves unrelated channels' paginators are not walked on a user event. +- [ ] `yarn types`, `yarn lint`, `yarn test` all green. + +--- + +## Execution order + +- **Phase 0 (design):** Task 1. +- **Phase 1 (core):** Task 2 (after Task 1). +- **Phase 2 (wire-up):** Task 3 (after Task 2), then Task 4 (after Task 3) — serialized because they + chain `ItemIndex` → paginator → client. +- **Phase 3 (verify):** Task 5 (after Task 4). + +Little parallelism here — it's a short dependency chain through the ingest choke point. The main +value of the plan is sequencing and the design gate. + +## File ownership summary + +| Task | Creates/Modifies | +| ---- | ------------------------------------------------------------------------- | +| 1 | `specs/user-reference-index/decisions.md` | +| 2 | `src/pagination/ItemIndex.ts` (+ optional `UserReferenceIndex.ts`) + test | +| 3 | `src/pagination/paginators/*.ts`, `src/channel.ts` (aggregate, if D1=B) | +| 4 | `src/client.ts` | +| 5 | `test/unit/client.test.js`, `test/unit/pagination/*` | diff --git a/specs/user-reference-index/state.json b/specs/user-reference-index/state.json new file mode 100644 index 000000000..52f7b5045 --- /dev/null +++ b/specs/user-reference-index/state.json @@ -0,0 +1,25 @@ +{ + "tasks": { + "task-1-resolve-design-decisions": "pending", + "task-2-index-and-maintenance": "pending", + "task-3-expose-targeted-lookup": "pending", + "task-4-rewrite-client-handlers": "pending", + "task-5-tests-and-complexity": "pending" + }, + "flags": { + "blocked": false, + "blocked_reason": "", + "needs-review": false, + "decisions_resolved": "D1=open, D2=open, D3=open, D4=noted, D5=leave-userChannelReferences-as-is", + "ready_to_execute": false, + "ready_to_execute_reason": "Design gate: resolve D1/D2/D3 (Task 1) before implementation." + }, + "meta": { + "last_updated": "2026-07-21", + "active_task": null, + "worktree": "/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/user-reference-index", + "branch": "feat/user-reference-index", + "base_branch": "feat/message-paginator-master-merge", + "motivation": "Replace the O(active channels x loaded items) all-channel scan in client._updateUserMessageReferences / _deleteUserMessageReference (introduced when Channel._trackLatestMessage / per-author userChannelReferences registration was removed) with a targeted user->message-reference index." + } +} diff --git a/src/channel.ts b/src/channel.ts index 4e3119cd4..a07e2eff8 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -2412,11 +2412,10 @@ export class Channel { const isThreadMessage = event.message.parent_id && !event.message.show_in_channel; - if (this.state.isUpToDate || isThreadMessage) { - channelState.addMessageSorted(event.message, ownMessage); - } - if (!isThreadMessage) { + // ingestItem advances the paginator's tracked latest message (→ last_message_at). A + // message that arrives while the viewer has scrolled to an older window lands in the + // head interval, not the active one, so the view is preserved without an isUpToDate flag. this.messagePaginator.ingestItem(formatMessage(event.message)); // ingestItem auto-adds when pinned (matchesFilter { pinned: true }). this.pinnedMessagesPaginator.ingestItem(formatMessage(event.message)); @@ -2490,7 +2489,6 @@ export class Channel { if (event.message) { this._extendEventWithOwnReactions(event); const formattedMessage = formatMessage(event.message); - channelState.addMessageSorted(event.message, false, false); if (!event.message.parent_id) { this.messagePaginator.ingestItem(formattedMessage); this.messagePaginator.reflectQuotedMessageUpdate(formattedMessage); @@ -2520,7 +2518,6 @@ export class Channel { // system messages don't increment unread counts if (event.message) { - channelState.addMessageSorted(event.message); this.messagePaginator.ingestItem(formatMessage(event.message)); this.pinnedMessagesPaginator.ingestItem(formatMessage(event.message)); } @@ -2792,9 +2789,14 @@ export class Channel { this.state.membership = state.membership || {}; // The main message list is seeded into channel.messagePaginator (see Channel.query / - // client.hydrateActiveChannels). This maintains thread-reply state, performs stale-thread - // cleanup, and advances last_message_at for the initializing page. - this.state.addMessagesSorted(state.messages || [], false, true, true); + // client.hydrateActiveChannels), whose ingestion advances the tracked latest message + // (→ last_message_at). Re-assert it here from the response for the one path where the paginator + // seed is skipped: an already-loaded channel that the viewer has jumped away from (see + // client.hydrateActiveChannels), where re-seeding would clobber their window. Monotonic, so this + // is a no-op when the seed already advanced past these messages. + (state.messages || []).forEach((message) => + this.messagePaginator.trackLatestMessage(formatMessage(message)), + ); // Seed the pinned-messages paginator from the same response. this.pinnedMessagesPaginator.seedFirstPageSync( @@ -2931,6 +2933,5 @@ export class Channel { this.disconnected = true; this.messageReceiptsTracker.unregisterSubscriptions(); this.cooldownTimer.clearTimeout(); - this.state.setIsUpToDate(false); } } diff --git a/src/channel_state.ts b/src/channel_state.ts index b21158bf2..4771755fe 100644 --- a/src/channel_state.ts +++ b/src/channel_state.ts @@ -65,14 +65,6 @@ export class ChannelState { pending_messages: Array; unreadCount: number; membership: ChannelMemberResponse; - last_message_at: Date | null; - /** - * Flag which indicates if channel state contain latest/recent messages or no. - * This flag should be managed by UI sdks using a setter - setIsUpToDate. - * When false, any new message (received by websocket event - message.new) will not - * be pushed on to message list. - */ - isUpToDate: boolean; constructor(channel: Channel) { this._channel = channel; @@ -95,17 +87,6 @@ export class ChannelState { this.pending_messages = []; this.membership = {}; this.unreadCount = 0; - /** - * Flag which indicates if channel state contain latest/recent messages or no. - * This flag should be managed by UI sdks using a setter - setIsUpToDate. - * When false, any new message (received by websocket event - message.new) will not - * be pushed on to message list. - */ - this.isUpToDate = true; - this.last_message_at = - channel?.state?.last_message_at != null - ? new Date(channel.state.last_message_at) - : null; } get members() { @@ -124,6 +105,20 @@ export class ChannelState { this.membersStore.partialNext({ memberCount }); } + /** + * Timestamp of the channel's latest message, derived from the message paginator's tracked latest + * message (`channel.messagePaginator.latestMessage`), or `null` when nothing is tracked. Read by + * `ChannelPaginator` to sort the channel list. + * + * Read-only: `last_message_at` is a projection of the message paginator (the single source of + * truth for messages). Advance it by ingesting/tracking a message on `channel.messagePaginator`, + * not by assignment. (Removing the former writable setter is a breaking change — see + * `docs/breaking-changes-v14-v15.md`.) + */ + get last_message_at(): Date | null { + return this._channel?.messagePaginator?.latestMessage?.created_at ?? null; + } + get read() { return this.readStore.getLatestValue().read; } @@ -252,29 +247,6 @@ export class ChannelState { this.watcherStore.partialNext({ watcherCount }); } - /** - * addMessageSorted - Register a single message's channel-level side effects. - * - * The channel message list lives in `channel.messagePaginator` and thread replies in the - * `Thread` object now; this only advances `last_message_at` and records the user reference. - * - * @param {MessageResponse} newMessage A new message - * @param {boolean} timestampChanged Whether updating a message with changed created_at value. - * @param {boolean} addIfDoesNotExist Add message if it is not in the list, used to prevent out of order updated messages from being added. - */ - addMessageSorted( - newMessage: MessageResponse | LocalMessage, - timestampChanged = false, - addIfDoesNotExist = true, - ) { - return this.addMessagesSorted( - [newMessage], - timestampChanged, - false, - addIfDoesNotExist, - ); - } - /** * Takes the message object, parses the dates, sets `__html` * and sets the status to `received` if missing; returns a new message object. @@ -284,78 +256,6 @@ export class ChannelState { formatMessage = (message: MessageResponse | MessageResponseBase | LocalMessage) => formatMessage(message); - /** - * addMessagesSorted - Register channel-level side effects for a list of messages. - * - * The channel message list lives in `channel.messagePaginator` and thread replies in the `Thread` - * object now; this only records the user reference (for user-update propagation) and advances - * `last_message_at`. It is retained until the paginators fully own those concerns. - * - * @param {Array} newMessages A list of messages - * @param {boolean} timestampChanged Whether updating messages with changed created_at value. - * @param {boolean} initializing Whether channel is being initialized. - * @param {boolean} addIfDoesNotExist Add message if it is not in the list, used to prevent out of order updated messages from being added. - */ - addMessagesSorted( - newMessages: (MessageResponse | LocalMessage)[], - timestampChanged = false, - initializing = false, - addIfDoesNotExist = true, - ) { - // `timestampChanged` / `initializing` are retained for positional-call compatibility only — - // the message list and thread replies now live in the paginators, so neither affects this - // channel-meta path. (This method is slated for removal once the paginators own last_message_at - // and the user reference map.) - void timestampChanged; - void initializing; - for (let i = 0; i < newMessages.length; i += 1) { - if (newMessages[i].shadowed && addIfDoesNotExist) { - continue; - } - // Already-formatted messages have run through this side-effect path already; skip them. - const isMessageFormatted = newMessages[i].created_at instanceof Date; - if (isMessageFormatted) { - continue; - } - const message = this.formatMessage(newMessages[i]); - - if (message.user && this._channel?.cid) { - /** - * Store the reference to user for this channel, so that when we have to - * handle updates to user, we can use the reference map, to determine which - * channels need to be updated with updated user object. - */ - this._channel - .getClient() - .state.updateUserReference(message.user, this._channel.cid); - } - - const shouldSkipLastMessageAtUpdate = - this._channel.getConfig()?.skip_last_msg_update_for_system_msgs && - message.type === 'system'; - - if ( - !shouldSkipLastMessageAtUpdate && - (!this.last_message_at || - message.created_at.getTime() > this.last_message_at.getTime()) - ) { - this.last_message_at = new Date(message.created_at.getTime()); - } - } - } - - /** - * Setter for isUpToDate. - * - * @param isUpToDate Flag which indicates if channel state contain latest/recent messages or no. - * This flag should be managed by UI sdks using a setter - setIsUpToDate. - * When false, any new message (received by websocket event - message.new) will not - * be pushed on to message list. - */ - setIsUpToDate = (isUpToDate: boolean) => { - this.isUpToDate = isUpToDate; - }; - /** * clean - Remove stale data such as users that stayed in typing state for more than 5 seconds */ diff --git a/src/client.ts b/src/client.ts index cb8ddd2b4..783e3e95d 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1473,11 +1473,12 @@ export class StreamChat { * @param {UserResponse} user */ _updateUserMessageReferences = (user: UserResponse) => { - const refMap = this.state.userChannelReferences[user.id] || {}; - - for (const channelID in refMap) { - const channel = this.activeChannels[channelID]; - + // Scan all active channels rather than a user->channel reference map. Message authors are no + // longer registered as channel references (that registration was removed along with + // `Channel._trackLatestMessage`); `reflectUserUpdate` filters by author id internally, so it is + // a no-op on channels without this user's messages. + // The next step is to have user ItemIndex, where the update would be O(1) complexity + for (const channel of Object.values(this.activeChannels)) { if (!channel) continue; /** update the messages from this user. */ @@ -1502,10 +1503,9 @@ export class StreamChat { hardDelete = false, deletedAt?: LocalMessage['deleted_at'], ) => { - const refMap = this.state.userChannelReferences[user.id] || {}; - - for (const channelID in refMap) { - const channel = this.activeChannels[channelID]; + // Scan all active channels rather than a user->channel reference map (see + // `_updateUserMessageReferences`); `applyMessageDeletionForUser` filters by author id internally. + for (const channel of Object.values(this.activeChannels)) { if (channel) { /** deleted the messages from this user. */ channel.messagePaginator.applyMessageDeletionForUser({ diff --git a/src/offline-support/offline_support_api.ts b/src/offline-support/offline_support_api.ts index 6e658f50a..a688de79a 100644 --- a/src/offline-support/offline_support_api.ts +++ b/src/offline-support/offline_support_api.ts @@ -21,6 +21,7 @@ import { StateStore } from '../store'; import { channelHasReadEvents, channelTracksReadLocally, + formatMessage, localMessageToNewMessagePayload, runDetached, } from '../utils'; @@ -1293,7 +1294,7 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { timestampChanged: true, }); } - channel.state.addMessageSorted(newMessage, true); + channel.messagePaginator.trackLatestMessage(formatMessage(newMessage)); } return newMessageResponse; } diff --git a/src/pagination/paginators/MessageIntervalPaginator.ts b/src/pagination/paginators/MessageIntervalPaginator.ts index 67eeb9c76..22a6f4a7d 100644 --- a/src/pagination/paginators/MessageIntervalPaginator.ts +++ b/src/pagination/paginators/MessageIntervalPaginator.ts @@ -86,7 +86,17 @@ const DEFAULT_BACKEND_SORT: MessagePaginatorSort = { // server's default size is 100 const DEFAULT_CHANNEL_MESSAGE_LIST_PAGE_SIZE = 100; -export type MessagePaginatorState = PaginatorState; +export type MessagePaginatorState = PaginatorState & { + /** + * Id of the newest message tracked for `channel.state.last_message_at` derivation. Advanced by + * {@link MessageIntervalPaginator.trackLatestMessage} (monotonic by `created_at`, honoring the + * per-paginator {@link MessageIntervalPaginator.shouldAdvanceLatestMessage} predicate — e.g. the main + * list skips system messages when configured and thread-only replies always), resolved back to a + * message via the {@link MessageIntervalPaginator.latestMessage} getter (index lookup), and reset + * to `null` with the rest of the state on {@link MessageIntervalPaginator.clearStateAndCache}. + */ + latestMessageId?: string | null; +}; export type MessageQueryShape = MessagePaginationOptions | PinnedMessagePaginationOptions; /** @@ -139,9 +149,10 @@ export class MessageIntervalPaginator extends BasePaginator< LocalMessage, MessageQueryShape > { + declare state: StateStore; private readonly _id: string; protected channel: Channel; - private parentMessageId?: string; + protected parentMessageId?: string; readonly messageFocusSignal: StateStore; private clearMessageFocusSignalTimeoutId: ReturnType | null = null; private messageFocusSignalToken = 0; @@ -157,6 +168,105 @@ export class MessageIntervalPaginator extends BasePaginator< return !message.shadowed; } + /** + * Source of truth for the tracked latest message. Kept as an instance field rather than only in + * `state` so it can be advanced from inside `ingestPage`/`setItems` (which run within a + * `state.next` updater, where a nested `partialNext` would be clobbered by the outer update). A + * state preprocessor mirrors this into {@link MessagePaginatorState.latestMessageId} on every + * emission, so subscribers stay reactive. + */ + private _latestMessageId: string | null = null; + + get initialState(): MessagePaginatorState { + return { ...super.initialState, latestMessageId: null }; + } + + /** + * The newest message of this list, resolved from the item index by the tracked id. Deliberately + * index-resolved rather than derived from the head window, so it is unaffected by which window is + * active OR by the interval/item sort orientation, and stays current across edits/reactions. This + * is the source of truth for `channel.state.last_message_at`. Returns `undefined` once nothing is + * tracked or the tracked message is no longer in the index. + */ + get latestMessage(): LocalMessage | undefined { + return this._latestMessageId ? this.getItem(this._latestMessageId) : undefined; + } + + /** + * Whether `message` may advance the tracked latest message. Base excludes only shadowed messages; + * subclasses narrow it to their notion of "the latest message of this list" (e.g. + * {@link MessagePaginator} additionally excludes thread-only replies and — when configured — + * system messages, but only for the main channel list). + */ + protected shouldAdvanceLatestMessage(message: LocalMessage): boolean { + return !message.shadowed; + } + + /** + * Monotonically advance the tracked latest message id to `message` when it is newer (by + * `created_at`) than the currently tracked one and {@link MessageIntervalPaginator.shouldAdvanceLatestMessage} + * permits it. Updates only the instance field (no state write) so it is safe to call from inside a + * `state.next` updater; the mirror preprocessor reflects it on the next emission. Returns `true` + * when the pointer moved. + * + * The current latest's timestamp is resolved from the item index rather than cached: there is no + * interval trimming, so the tracked message stays resolvable while loaded; the only way it leaves + * the index is deletion (its author hard-deleted / an explicit remove), and in that case advancing + * to the next newest loaded message is the correct outcome, not a regression. + */ + protected advanceLatestTo(message: LocalMessage): boolean { + if (!this.shouldAdvanceLatestMessage(message)) return false; + const incomingTimestamp = getMessageCreatedAtTimestamp(message); + if (incomingTimestamp === null) return false; + const current = this.latestMessage; + const currentTimestamp = current ? getMessageCreatedAtTimestamp(current) : null; + if (currentTimestamp !== null && incomingTimestamp <= currentTimestamp) return false; + this._latestMessageId = message.id; + return true; + } + + /** + * Explicitly track `message` as the newest message of this list, for callers that advance it + * without a normal in-window ingest — the channel-open seed reconciliation (`_initializeState`, + * whose paginator seed is skipped for an already-loaded channel jumped to an older window) and + * offline pending-message replay. The message is upserted into the item index so + * {@link MessageIntervalPaginator.latestMessage} can resolve it. Advancement is monotonic (see + * {@link MessageIntervalPaginator.advanceLatestTo}). + * + * Intentionally does NOT emit: the field is the source of truth (read synchronously by + * `latestMessage` / `channel.state.last_message_at`), and the mirror preprocessor publishes it into + * `state.latestMessageId` on the next emission. + */ + trackLatestMessage(message: LocalMessage) { + if (!this.advanceLatestTo(message)) return; + this._itemIndex.setOne(message); + } + + ingestItem(item: LocalMessage): boolean { + // Advance BEFORE delegating so `super.ingestItem`'s own emission already reflects the new latest + // (the mirror preprocessor reads the field). Only items that survive the filter are tracked - + // `ingestItem` also handles removals of items that no longer match. The field-only advance does + // not touch the index, so it cannot confuse `super`'s new-vs-existing item diffing, and it reads + // the *previous* latest (still indexed) for the monotonic comparison. + if (this.matchesFilter(item)) { + this.advanceLatestTo(item); + } + return super.ingestItem(item); + } + + ingestPage( + params: Parameters['ingestPage']>[0], + ): Interval | null { + const interval = super.ingestPage(params); + // Advance AFTER delegating: page items reference each other by index for the monotonic + // comparison, and they are only in the index once `super.ingestPage` has run. The committing + // emission (this method's caller - `setItems` / query / `mergeNewestPage`) mirrors the field. + if (params.page?.length) { + for (const item of params.page) this.advanceLatestTo(item); + } + return interval; + } + protected get intervalItemIdsAreHeadFirst(): boolean { // Messages are stored in chronological order (created_at asc) within an interval. // Pagination "head" (newest side) is therefore at the END of the `itemIds` array. @@ -216,6 +326,14 @@ export class MessageIntervalPaginator extends BasePaginator< }, }); this.setFilterResolvers([dataFieldFilterResolver]); + + // Mirror the tracked-latest source-of-truth field into reactive state on every emission. Doing + // it in a preprocessor (rather than a `partialNext` at each advance) means advances made from + // inside a `state.next` updater - `ingestPage` via `setItems`/query - are reflected by the very + // update that commits the page, instead of being clobbered by it. + this.state.addPreprocessor((nextValue) => { + nextValue.latestMessageId = this._latestMessageId; + }); } get id() { @@ -694,6 +812,8 @@ export class MessageIntervalPaginator extends BasePaginator< }; clearStateAndCache() { + // Clear the source-of-truth field first so the `resetState` emission mirrors `null`. + this._latestMessageId = null; this.resetState(); this._itemIndex.clear(); this.clearMessageFocusSignal(); diff --git a/src/pagination/paginators/MessagePaginator.ts b/src/pagination/paginators/MessagePaginator.ts index 68655e24c..f9c27f518 100644 --- a/src/pagination/paginators/MessagePaginator.ts +++ b/src/pagination/paginators/MessagePaginator.ts @@ -95,6 +95,28 @@ export class MessagePaginator extends MessageIntervalPaginator { }); } + /** + * The main channel list's notion of "latest message" for `channel.state.last_message_at`: on top of + * the base rule (not shadowed) this excludes thread-only replies (a reply with a `parent_id` that is + * not shown in the channel is not part of the channel list) and, when the channel is configured with + * `skip_last_msg_update_for_system_msgs`, system messages. Mirrors the legacy + * `Channel._trackLatestMessage` skip logic. + * + * These exclusions are specific to the MAIN channel list. A reply list (thread paginator, which has + * a `parentMessageId`) is made ENTIRELY of thread replies — there the newest reply is exactly the + * "latest message", so the exclusions are skipped and only the base rule applies. + */ + protected shouldAdvanceLatestMessage(message: LocalMessage): boolean { + if (!super.shouldAdvanceLatestMessage(message)) return false; + if (this.parentMessageId) return true; + const isThreadOnlyReply = !!message.parent_id && !message.show_in_channel; + if (isThreadOnlyReply) return false; + const skipSystemMessage = + !!this.channel.getConfig?.()?.skip_last_msg_update_for_system_msgs && + message.type === 'system'; + return !skipSystemMessage; + } + /** * (Re)seed the unread state snapshot from the current own read state. * diff --git a/test/unit/channel.test.js b/test/unit/channel.test.js index c5b1c7cc5..f173d337a 100644 --- a/test/unit/channel.test.js +++ b/test/unit/channel.test.js @@ -66,7 +66,6 @@ describe('Channel count unread', function () { mentioned_users: [user], }), ]; - channel.state.addMessagesSorted(ignoredMessages); }); it('_countMessageAsUnread should return false shadowed or silent messages', function () { @@ -218,7 +217,6 @@ describe('Channel count unread', function () { user: user, unread_messages: 0, }; - channel.state.addMessagesSorted(messages); expect(channel.lastRead()).to.eq(last_read); }); @@ -2656,18 +2654,13 @@ describe('Channel lastMessage', async () => { config: { skip_last_msg_update_for_system_msgs: true }, }); channel.state = new ChannelState(channel); - const latestMessageDate = '2018-01-01T00:13:24'; const latestMessages = [ - generateMsg({ date: latestMessageDate, type: 'system' }), + generateMsg({ date: '2018-01-01T00:13:24', type: 'system' }), generateMsg({ date: '2018-01-01T00:02:00' }), generateMsg({ date: '2018-01-01T00:00:00' }), ]; - const otherMessages = [ - generateMsg({ date: '2017-11-21T00:05:33' }), - generateMsg({ date: '2017-11-21T00:05:35' }), - ]; - channel.state.addMessagesSorted(latestMessages); - channel.state.addMessagesSorted(otherMessages); + // ingestion advances the tracked latest, skipping the newest (system) message per config. + seedLatestWindow(channel, latestMessages); expect(channel.state.last_message_at.getTime()).toBe( new Date(latestMessages[1].created_at).getTime(), @@ -2675,6 +2668,57 @@ describe('Channel lastMessage', async () => { }); }); +describe('Channel last_message_at', () => { + let channel; + let client; + beforeEach(async () => { + client = await getClientWithUser(); + channel = client.channel('messaging', uuidv4()); + client._addChannelConfig({ cid: channel.cid, config: {} }); + channel.state = new ChannelState(channel); + }); + + const track = (msg) => channel.messagePaginator.trackLatestMessage(formatMessage(msg)); + + it('advances monotonically as messages are tracked', () => { + expect(channel.state.last_message_at).to.be.null; + track(generateMsg({ id: '0', date: '2020-01-01T00:00:00.000Z' })); + expect(channel.state.last_message_at.getTime()).to.be.equal( + new Date('2020-01-01T00:00:00.000Z').getTime(), + ); + track(generateMsg({ id: '1', date: '2019-01-01T00:00:00.000Z' })); + expect(channel.state.last_message_at.getTime()).to.be.equal( + new Date('2020-01-01T00:00:00.000Z').getTime(), + ); + + track(generateMsg({ id: '2', date: '2020-01-01T00:00:00.001Z' })); + expect(channel.state.last_message_at.getTime()).to.be.equal( + new Date('2020-01-01T00:00:00.001Z').getTime(), + ); + }); + + it('derives from the message paginator latestMessage', () => { + track(generateMsg({ id: '0', date: '2020-01-01T00:00:00.000Z' })); + expect(channel.messagePaginator.latestMessage?.id).to.be.equal('0'); + expect(channel.state.last_message_at.getTime()).to.be.equal( + channel.messagePaginator.latestMessage.created_at.getTime(), + ); + }); + + it('is not advanced by a thread-only reply', () => { + track( + generateMsg({ id: 'reply', date: '2020-01-01T00:00:00.000Z', parent_id: 'parent' }), + ); + + expect(channel.state.last_message_at).to.be.null; + }); + + it('is null when no message is tracked (derived from the paginator, read-only)', () => { + // The writable setter was removed: last_message_at is a projection of the message paginator. + expect(channel.state.last_message_at).to.be.null; + }); +}); + describe('Channel _initializeState', () => { it('should not keep members that have unwatched since last watch', async () => { const client = await getClientWithUser(); diff --git a/test/unit/channel_state.test.js b/test/unit/channel_state.test.js index c664f88eb..80f15d9ad 100644 --- a/test/unit/channel_state.test.js +++ b/test/unit/channel_state.test.js @@ -1,5 +1,4 @@ import { generateChannel } from './test-utils/generateChannel'; -import { generateMsg } from './test-utils/generateMessage'; import { getClientWithUser } from './test-utils/getClient'; import { getOrCreateChannelApi } from './test-utils/getOrCreateChannelApi'; @@ -7,69 +6,9 @@ import { ChannelState, StreamChat, Channel } from '../../src'; import { generateUUIDv4 as uuidv4 } from '../../src/utils'; import { vi, describe, beforeEach, afterEach, it, expect } from 'vitest'; -import { MockOfflineDB } from './offline-support/MockOfflineDB'; const toISOString = (timestampMs) => new Date(timestampMs).toISOString(); -describe('ChannelState addMessagesSorted', function () { - let state; - let client; - - beforeEach(async () => { - client = new StreamChat(); - const offlineDb = new MockOfflineDB({ client }); - - client.setOfflineDBApi(offlineDb); - await client.offlineDb.init(client.userID); - const channel = new Channel(client, 'type', 'id', {}); - client._addChannelConfig({ cid: channel.cid, config: {} }); - state = new ChannelState(channel); - }); - - it('updates last_message_at correctly', async function () { - expect(state.last_message_at).to.be.null; - state.addMessagesSorted([generateMsg({ id: '0', date: '2020-01-01T00:00:00.000Z' })]); - expect(state.last_message_at.getTime()).to.be.equal( - new Date('2020-01-01T00:00:00.000Z').getTime(), - ); - state.addMessagesSorted([generateMsg({ id: '1', date: '2019-01-01T00:00:00.000Z' })]); - expect(state.last_message_at.getTime()).to.be.equal( - new Date('2020-01-01T00:00:00.000Z').getTime(), - ); - - state.addMessagesSorted([generateMsg({ id: '2', date: '2020-01-01T00:00:00.001Z' })]); - expect(state.last_message_at.getTime()).to.be.equal( - new Date('2020-01-01T00:00:00.001Z').getTime(), - ); - }); -}); - -describe('ChannelState isUpToDate', () => { - it('isUpToDate flag should be set to false, when watcher is disconnected', async () => { - const chatClient = await getClientWithUser(); - const channelId = uuidv4(); - const mockedChannelResponse = generateChannel({ - channel: { - id: channelId, - }, - }); - - // to mock the channel.watch call - chatClient.post = () => getOrCreateChannelApi(mockedChannelResponse).response.data; - const channel = chatClient.channel('messaging', channelId); - - await channel.watch(); - // This is a responsibility of application layer to set the flag, depending - // on what state is queried - most recent or some older. - channel.state.setIsUpToDate(true); - - expect(channel.state.isUpToDate).to.be.eq(true); - - await channel._disconnect(); - expect(channel.state.isUpToDate).to.be.eq(false); - }); -}); - describe('ChannelState clean', () => { let client; let channel; diff --git a/test/unit/client.test.js b/test/unit/client.test.js index 7de0fabc1..6f0b8713c 100644 --- a/test/unit/client.test.js +++ b/test/unit/client.test.js @@ -1558,8 +1558,6 @@ describe('user.updated propagates to message + pinned paginators', () => { pinned_at: '2020-01-01T00:00:00.000Z', }); - // addMessagesSorted registers the user->channel reference that _updateUserMessageReferences walks. - channel.state.addMessagesSorted([message]); channel.messagePaginator.setItems({ valueOrFactory: [message], isFirstPage: true, @@ -1591,8 +1589,9 @@ describe('user.messages.deleted (client-level, cross-channel)', () => { client = await getClientWithUser(); }); - // Seeds a channel with one main + one pinned message from the banned user (plus a pinned message - // from another user), registering the user->channel reference the client-level loop walks. + // Seeds a channel (registered as active by `client.channel`) with one main + one pinned message + // from the banned user, plus a pinned message from another user. The client-level loop scans all + // active channels, so no explicit user->channel reference registration is needed. const setupChannel = (id) => { const channel = client.channel('messaging', id); const main = generateMsg({ id: `${id}-m`, cid: channel.cid, user: bannedUser }); @@ -1610,7 +1609,6 @@ describe('user.messages.deleted (client-level, cross-channel)', () => { pinned: true, pinned_at: '2020-01-02T00:00:00.000Z', }); - channel.state.addMessagesSorted([main]); channel.messagePaginator.setItems({ valueOrFactory: [main], isFirstPage: true, @@ -1704,10 +1702,9 @@ describe('user.messages.deleted — quoted_message regression (#1736)', () => { quoted_message_id: m1.id, }); const channel = client.channel(type, id); - // addMessagesSorted registers the user->channel reference (client.state.userChannelReferences) - // that the client-level deletion loop walks; setItems puts the messages in the paginator - // (the message list source of truth) so the deletion has something to act on. - channel.state.addMessagesSorted([m1, m2]); + // `client.channel` registers the channel as active; the client-level deletion loop scans all + // active channels, and setItems puts the messages in the paginator (the message list source of + // truth) so the deletion has something to act on. channel.messagePaginator.setItems({ valueOrFactory: [m1, m2], isFirstPage: true, diff --git a/test/unit/offline-support/offline_support_api.test.ts b/test/unit/offline-support/offline_support_api.test.ts index 6ecbba896..a8b450200 100644 --- a/test/unit/offline-support/offline_support_api.test.ts +++ b/test/unit/offline-support/offline_support_api.test.ts @@ -2103,7 +2103,7 @@ describe('OfflineSupportApi', () => { _deleteReaction: vi.fn(), _createDraft: vi.fn(), _deleteDraft: vi.fn(), - state: { addMessageSorted: vi.fn() }, + messagePaginator: { trackLatestMessage: vi.fn() }, } as unknown as Channel; _updateMessageSpy = vi @@ -2169,7 +2169,7 @@ describe('OfflineSupportApi', () => { expect(mockChannel._deleteDraft).toHaveBeenCalledWith(...task.payload); }); - it('should call _sendMessage and addMessageSorted if isPendingTask is true', async () => { + it('should call _sendMessage and track the latest message if isPendingTask is true', async () => { const task = generatePendingTask('send-message') as PendingTask; const messageResponse = { message: { id: 'msg1', text: 'hello' } }; @@ -2180,9 +2180,8 @@ describe('OfflineSupportApi', () => { await offlineDb['executeTask']({ task }, true); expect(mockChannel._sendMessage).toHaveBeenCalledWith(...task.payload); - expect(mockChannel.state.addMessageSorted).toHaveBeenCalledWith( - messageResponse.message, - true, + expect(mockChannel.messagePaginator.trackLatestMessage).toHaveBeenCalledWith( + expect.objectContaining({ id: 'msg1' }), ); }); diff --git a/test/unit/pagination/paginators/ChannelPaginator.test.ts b/test/unit/pagination/paginators/ChannelPaginator.test.ts index d74e2340a..210e896d4 100644 --- a/test/unit/pagination/paginators/ChannelPaginator.test.ts +++ b/test/unit/pagination/paginators/ChannelPaginator.test.ts @@ -7,15 +7,29 @@ import { ChannelSort, DEFAULT_PAGINATION_OPTIONS, type FilterBuilderGenerators, + formatMessage, PaginatorCursor, type StreamChat, } from '../../../../src'; import { getClientWithUser } from '../../test-utils/getClient'; +import { generateMsg } from '../../test-utils/generateMessage'; import type { FieldToDataResolver } from '../../../../src/pagination/types.normalization'; import { MockOfflineDB } from '../../offline-support/MockOfflineDB'; const user = { id: 'custom-id' }; +// `channel.state.last_message_at` is derived (read-only) from the message paginator's tracked latest +// message. To stage a specific value for sort tests, seed the paginator: clear first so any value +// (including an earlier one) applies, since tracking is monotonic. +const setLastMessageAt = (channel: Channel, date: Date | null) => { + channel.messagePaginator.clearStateAndCache(); + if (date) { + channel.messagePaginator.trackLatestMessage( + formatMessage(generateMsg({ date: date.toISOString() })), + ); + } +}; + describe('ChannelPaginator', () => { let client: StreamChat; let channel1: Channel; @@ -25,11 +39,11 @@ describe('ChannelPaginator', () => { client = getClientWithUser(user); channel1 = new Channel(client, 'type', 'id1', {}); - channel1.state.last_message_at = new Date('1972-01-01T08:39:35.235Z'); + setLastMessageAt(channel1, new Date('1972-01-01T08:39:35.235Z')); channel1.data!.updated_at = '1972-01-01T08:39:35.235Z'; channel2 = new Channel(client, 'type', 'id1', {}); - channel2.state.last_message_at = new Date('1971-01-01T08:39:35.235Z'); + setLastMessageAt(channel2, new Date('1971-01-01T08:39:35.235Z')); channel2.data!.updated_at = '1971-01-01T08:39:35.235Z'; }); @@ -49,10 +63,10 @@ describe('ChannelPaginator', () => { expect(paginator.id.startsWith('channel-paginator')).toBeTruthy(); expect(paginator.sortComparator).toBeDefined(); - channel1.state.last_message_at = new Date('1970-01-01T08:39:35.235Z'); + setLastMessageAt(channel1, new Date('1970-01-01T08:39:35.235Z')); channel1.data!.updated_at = '1970-01-01T08:39:35.235Z'; - channel2.state.last_message_at = new Date('1971-01-01T08:39:35.235Z'); + setLastMessageAt(channel2, new Date('1971-01-01T08:39:35.235Z')); channel2.data!.updated_at = '1971-01-01T08:39:35.235Z'; expect(paginator.sortComparator(channel1, channel2)).toBe(1); // channel2 comes before channel1 @@ -152,10 +166,10 @@ describe('ChannelPaginator', () => { const paginator = new ChannelPaginator({ client }); expect(paginator.sortComparator(channel1, channel2)).toBe(keepOrder); - channel1.state.last_message_at = new Date('1970-01-01T08:39:35.235Z'); + setLastMessageAt(channel1, new Date('1970-01-01T08:39:35.235Z')); channel1.data!.updated_at = '1970-01-01T08:39:35.235Z'; - channel2.state.last_message_at = new Date('1971-01-01T08:39:35.235Z'); + setLastMessageAt(channel2, new Date('1971-01-01T08:39:35.235Z')); channel2.data!.updated_at = '1971-01-01T08:39:35.235Z'; expect(paginator.sortComparator(channel1, channel2)).toBe(changeOrder); @@ -201,16 +215,16 @@ describe('ChannelPaginator', () => { const paginator = new ChannelPaginator({ client, sort: { last_updated: 1 } }); // compares channel1.state.last_message_at with channel2.data!.updated_at - channel1.state.last_message_at = new Date('1975-01-01T08:39:35.235Z'); + setLastMessageAt(channel1, new Date('1975-01-01T08:39:35.235Z')); channel1.data!.updated_at = '1970-01-01T08:39:35.235Z'; - channel2.state.last_message_at = new Date('1971-01-01T08:39:35.235Z'); + setLastMessageAt(channel2, new Date('1971-01-01T08:39:35.235Z')); channel2.data!.updated_at = '1973-01-01T08:39:35.235Z'; expect(paginator.sortComparator(channel1, channel2)).toBe(changeOrder); // compares channel2.state.last_message_at with channel1.data!.updated_at - channel1.state.last_message_at = new Date('1975-01-01T08:39:35.235Z'); + setLastMessageAt(channel1, new Date('1975-01-01T08:39:35.235Z')); channel1.data!.updated_at = '1976-01-01T08:39:35.235Z'; - channel2.state.last_message_at = new Date('1978-01-01T08:39:35.235Z'); + setLastMessageAt(channel2, new Date('1978-01-01T08:39:35.235Z')); channel2.data!.updated_at = '1973-01-01T08:39:35.235Z'; expect(paginator.sortComparator(channel1, channel2)).toBe(keepOrder); }); @@ -365,17 +379,17 @@ describe('ChannelPaginator', () => { filters: { last_updated: new Date(1000).toISOString() }, }); channel1.data = { updated_at: undefined }; - channel1.state.last_message_at = new Date(1000); + setLastMessageAt(channel1, new Date(1000)); expect(paginator.matchesFilter(channel1)).toBeTruthy(); channel1.data = { updated_at: new Date(1000).toISOString() }; - channel1.state.last_message_at = null; + setLastMessageAt(channel1, null); expect(paginator.matchesFilter(channel1)).toBeTruthy(); channel1.data = { updated_at: undefined }; - channel1.state.last_message_at = null; + setLastMessageAt(channel1, null); expect(paginator.matchesFilter(channel1)).toBeFalsy(); }); @@ -429,18 +443,18 @@ describe('ChannelPaginator', () => { channel1.data = { updated_at: undefined }; scenarios.forEach(({ val, expected }) => { - channel1.state.last_message_at = new Date(val); + setLastMessageAt(channel1, new Date(val)); expect(paginator.matchesFilter(channel1)).toBe(expected); }); - channel1.state.last_message_at = null; + setLastMessageAt(channel1, null); scenarios.forEach(({ val, expected }) => { channel1.data = { updated_at: new Date(val).toISOString() }; expect(paginator.matchesFilter(channel1)).toBe(expected); }); channel1.data = { updated_at: undefined }; - channel1.state.last_message_at = null; + setLastMessageAt(channel1, null); expect(paginator.matchesFilter(channel1)).toBe(false); }); }); diff --git a/test/unit/pagination/paginators/MessagePaginator.test.ts b/test/unit/pagination/paginators/MessagePaginator.test.ts index 64c6745bf..ea5149023 100644 --- a/test/unit/pagination/paginators/MessagePaginator.test.ts +++ b/test/unit/pagination/paginators/MessagePaginator.test.ts @@ -48,6 +48,7 @@ describe('MessagePaginator', () => { isLoading: false, items: undefined, lastQueryError: undefined, + latestMessageId: null, offset: 0, }); // @ts-expect-error accessing protected property @@ -1414,7 +1415,7 @@ describe('MessagePaginator', () => { }); }); - describe('latest window, truncation & isUpToDate parity', () => { + describe('latest window, truncation & live message routing', () => { const msg = (id: string, day: string) => createMessage({ cid: 'channel-id', @@ -1632,7 +1633,7 @@ describe('MessagePaginator', () => { }); }); - describe('isUpToDate parity — message.new routing', () => { + describe('message.new routing (replaces the isUpToDate flag)', () => { it('appends a newer message when the head window is active', () => { const paginator = new MessagePaginator({ channel, itemIndex }); paginator.ingestPage({ @@ -1648,7 +1649,7 @@ describe('MessagePaginator', () => { expect(paginator.latestItem?.id).toBe('m3'); }); - it('does not inject a newer message into an older active window (isUpToDate=false analog)', () => { + it('does not inject a newer message into an older active window (viewer scrolled away)', () => { const paginator = new MessagePaginator({ channel, itemIndex }); paginator.ingestPage({ page: [msg('m8', '08'), msg('m9', '09')], @@ -1905,7 +1906,198 @@ describe('MessagePaginator', () => { }); }); - it('cannot be customized', () => { - const paginator = new MessagePaginator({ channel, itemIndex }); + describe('trackLatestMessage() / latestMessage', () => { + let skipSystemMessages: boolean; + let trackingChannel: Channel; + + const buildPaginator = (parentMessageId?: string) => { + trackingChannel = { + cid: 'channel-id', + getConfig: () => ({ skip_last_msg_update_for_system_msgs: skipSystemMessages }), + getReplies: vi.fn(), + query: vi.fn(), + } as unknown as Channel; + return new MessagePaginator({ + channel: trackingChannel, + parentMessageId, + itemIndex: new ItemIndex({ getId: (message) => message.id }), + }); + }; + + beforeEach(() => { + skipSystemMessages = false; + }); + + it('is undefined until a message is tracked', () => { + const paginator = buildPaginator(); + expect(paginator.state.getLatestValue().latestMessageId).toBeNull(); + expect(paginator.latestMessage).toBeUndefined(); + }); + + it('tracks a message and resolves it from the index without ingesting a window', () => { + const paginator = buildPaginator(); + const message = createMessage({ id: 'a', created_at: '2020-01-01T00:00:00.000Z' }); + + paginator.trackLatestMessage(message); + + // `trackLatestMessage` updates the source-of-truth field (read by `latestMessage`) without + // emitting; `state.latestMessageId` is published by the mirror preprocessor on the next + // emission (verified in the reply-list auto-track cases below). + expect(paginator.latestMessage?.id).toBe('a'); + // tracked without ingesting into an interval - the visible window stays empty. + expect(paginator.items).toBeUndefined(); + }); + + it('does not emit on its own, so the paired ingest is the single state update', () => { + const paginator = buildPaginator(); + let emissions = 0; + const unsubscribe = paginator.state.subscribe(() => { + emissions += 1; + }); + emissions = 0; // ignore the synchronous initial subscribe call + + paginator.trackLatestMessage( + createMessage({ id: 'a', created_at: '2020-01-01T00:00:00.000Z' }), + ); + unsubscribe(); + + expect(emissions).toBe(0); + // the field is still updated (read by latestMessage / last_message_at) + expect(paginator.latestMessage?.id).toBe('a'); + }); + + it('advances monotonically by created_at', () => { + const paginator = buildPaginator(); + const first = createMessage({ id: 'a', created_at: '2020-01-01T00:00:00.000Z' }); + const older = createMessage({ id: 'b', created_at: '2019-01-01T00:00:00.000Z' }); + const newer = createMessage({ id: 'c', created_at: '2021-01-01T00:00:00.000Z' }); + + paginator.trackLatestMessage(first); + paginator.trackLatestMessage(older); + expect(paginator.latestMessage?.id).toBe('a'); + + paginator.trackLatestMessage(newer); + expect(paginator.latestMessage?.id).toBe('c'); + }); + + it('never advances to a shadowed message', () => { + const paginator = buildPaginator(); + const shadowed = createMessage({ + id: 'a', + created_at: '2020-01-01T00:00:00.000Z', + shadowed: true, + }); + + paginator.trackLatestMessage(shadowed); + + expect(paginator.latestMessage).toBeUndefined(); + }); + + it('never advances to a thread-only reply, but does for a reply shown in the channel', () => { + const paginator = buildPaginator(); + const threadOnlyReply = createMessage({ + id: 'reply', + parent_id: 'parent', + created_at: '2020-01-01T00:00:00.000Z', + }); + const shownReply = createMessage({ + id: 'reply-shown', + parent_id: 'parent', + show_in_channel: true, + created_at: '2021-01-01T00:00:00.000Z', + }); + + paginator.trackLatestMessage(threadOnlyReply); + expect(paginator.latestMessage).toBeUndefined(); + + paginator.trackLatestMessage(shownReply); + expect(paginator.latestMessage?.id).toBe('reply-shown'); + }); + + it('skips system messages only when skip_last_msg_update_for_system_msgs is set', () => { + skipSystemMessages = true; + const skipping = buildPaginator(); + const systemMessage = createMessage({ + id: 'sys', + type: 'system', + created_at: '2020-01-01T00:00:00.000Z', + }); + skipping.trackLatestMessage(systemMessage); + expect(skipping.latestMessage).toBeUndefined(); + + skipSystemMessages = false; + const tracking = buildPaginator(); + tracking.trackLatestMessage(systemMessage); + expect(tracking.latestMessage?.id).toBe('sys'); + }); + + it('auto-tracks on ingestion for the main channel list too', () => { + const paginator = buildPaginator(); + paginator.ingestItem( + createMessage({ + id: 'a', + cid: 'channel-id', + created_at: '2020-01-01T00:00:00.000Z', + }), + ); + // The main list no longer relies on an explicit channel-level call: ingestion tracks latest, + // and last_message_at is derived from it. + expect(paginator.latestMessage?.id).toBe('a'); + }); + + describe('reply list (parentMessageId) auto-tracks on ingestion', () => { + const reply = (id: string, createdAt: string): LocalMessage => + createMessage({ + id, + cid: 'channel-id', + parent_id: 'parent', + created_at: createdAt, + }); + + it('advances to the newest reply on ingestItem, regardless of ingestion order', () => { + const paginator = buildPaginator('parent'); + + paginator.ingestItem(reply('r2', '2020-01-01T00:00:02.000Z')); + expect(paginator.latestMessage?.id).toBe('r2'); + + // an older reply arriving later must not move the pointer back + paginator.ingestItem(reply('r1', '2020-01-01T00:00:01.000Z')); + expect(paginator.latestMessage?.id).toBe('r2'); + + paginator.ingestItem(reply('r3', '2020-01-01T00:00:03.000Z')); + expect(paginator.latestMessage?.id).toBe('r3'); + }); + + it('advances to the newest reply when a page is seeded via setItems', () => { + const paginator = buildPaginator('parent'); + + paginator.setItems({ + valueOrFactory: [ + reply('r1', '2020-01-01T00:00:01.000Z'), + reply('r3', '2020-01-01T00:00:03.000Z'), + reply('r2', '2020-01-01T00:00:02.000Z'), + ], + isFirstPage: true, + }); + + expect(paginator.latestMessage?.id).toBe('r3'); + // The mirror preprocessor publishes the tracked id into state on the setItems emission - + // no separate emission from the tracking itself. + expect(paginator.state.getLatestValue().latestMessageId).toBe('r3'); + }); + }); + + it('clears the tracked latest message on clearStateAndCache()', () => { + const paginator = buildPaginator(); + paginator.trackLatestMessage( + createMessage({ id: 'a', created_at: '2020-01-01T00:00:00.000Z' }), + ); + expect(paginator.latestMessage?.id).toBe('a'); + + paginator.clearStateAndCache(); + + expect(paginator.state.getLatestValue().latestMessageId).toBeNull(); + expect(paginator.latestMessage).toBeUndefined(); + }); }); }); diff --git a/test/unit/poll_manager.test.ts b/test/unit/poll_manager.test.ts index 07b09a985..422f32d26 100644 --- a/test/unit/poll_manager.test.ts +++ b/test/unit/poll_manager.test.ts @@ -280,7 +280,6 @@ describe('PollManager', () => { const channel = client.channel('messaging', mockChannelQueryResponse.channel.id); const { messages: prevMessages, pollMessages: prevPollMessages } = generateRandomMessagesWithPolls(5, `_prev`); - channel.state.addMessagesSorted(prevMessages); const { messages, pollMessages } = generateRandomMessagesWithPolls(5, ``); const mockedChannelQueryResponse = { ...mockChannelQueryResponse, From 2e37f40dd226b14ba3500cdb5cf0a864d6d72a9f Mon Sep 17 00:00:00 2001 From: martincupela Date: Tue, 21 Jul 2026 14:57:44 +0200 Subject: [PATCH 19/25] docs: point event examples and CLAUDE.md at messagePaginator (Task 14) - Channel.on / StreamChat.on JSDoc examples logged the removed channel.state.messages; use channel.messagePaginator.state.items instead. - CLAUDE.md: note that messages / thread replies / pinned messages live in the paginators (channel.messagePaginator / thread.messagePaginator / channel.pinnedMessagesPaginator) as the single source of truth, not channel.state; how to read/mutate; the read-only derived last_message_at; pointer to docs/breaking-changes-v14-v15.md. - Mark Task 14 done (JS) in the remove-legacy-channelstate-messages spec. The React CLAUDE.md stale guidance remains and is tracked separately on the React branch. Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 2 +- .../plan.md | 19 ++++++++++++++++--- .../state.json | 7 ++++--- src/channel.ts | 2 +- src/client.ts | 2 +- 5 files changed, 23 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 29e93c200..c7a34c0e9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,7 +52,7 @@ This is a single-package SDK with **no monorepo**. The public surface is everyth ### Module map of `src/` - **`client.ts` — `StreamChat` facade.** ~5k-line class. Prefer `StreamChat.getInstance(key, secret?, options?)` — the constructor exists for advanced uses but `getInstance` is what `connectUser` warnings and most docs assume. Owns: the axios instance, WS connection lifecycle, `TokenManager`, and a registry of subsystem managers (`threads`, `polls`, `notifications`, `reminders`, `moderation`, `uploadManager`, `messageDeliveryReporter`, plus an optional `offlineDb` injected via `setOfflineDBApi`). New REST endpoints are added here as methods that call `axiosInstance` and return a type from `types.ts`. -- **`channel.ts` (~2.5k lines) + `channel_state.ts` (~1.1k) + `channel_manager.ts` + `channel_batch_updater.ts`** — per-channel object, its in-memory state, and the manager that orchestrates collections of channels (query/sort/filter, pagination, archived/pinned handling). +- **`channel.ts` (~2.5k lines) + `channel_state.ts` (~1.1k) + `channel_manager.ts` + `channel_batch_updater.ts`** — per-channel object, its in-memory state, and the manager that orchestrates collections of channels (query/sort/filter, pagination, archived/pinned handling). **Messages are NOT stored on `channel.state`.** The message list, thread replies, and pinned messages each live in a paginator — `channel.messagePaginator`, `thread.messagePaginator`, and `channel.pinnedMessagesPaginator` — which are the single source of truth (interval storage + a canonical `ItemIndex`). Read them via `channel.messagePaginator.state.items` / `.getItem(id)` / `.latestMessage`, and mutate via the paginator (`ingestItem` / `removeItem`), never a legacy `channel.state.addMessageSorted()` / `state.messages` (removed). `channel.state.last_message_at` is a read-only getter derived from `channel.messagePaginator.latestMessage`. See `docs/breaking-changes-v14-v15.md`. - **`connection.ts` (`StableWSConnection`) + `connection_fallback.ts` (`WSConnectionFallback`)** — realtime transport. Primary WS implementation does its own 25s ping / 35s health-check loop and reconnects on close/error/offline events; the fallback long-polls over HTTP. The client picks between them based on first-connect outcome; both emit `connection.changed` / `transport.changed` events into the client's local event bus. - **`store.ts` — `StateStore`.** Reactive primitive (see "State and subscription patterns" below). - **`signing.ts` — webhook + token helpers.** Server-side primitives `verifyAndParseWebhook`, `parseSqs`, `parseSns`, `verifySignature` (recent CHA-3071 added compressed-payload support). These are re-exported through `client.ts`. **The HMAC is always computed over the uncompressed JSON bytes** — gzip detection uses the `1f 8b` magic bytes, not headers, so the same handler works whether your platform middleware auto-decompressed or not. `CheckSignature` is deprecated in favor of `verifySignature` purely to fix parameter order; new code should use `verifySignature(body, signature, secret)`. diff --git a/specs/remove-legacy-channelstate-messages/plan.md b/specs/remove-legacy-channelstate-messages/plan.md index cd07648c2..e5dd8fa77 100644 --- a/specs/remove-legacy-channelstate-messages/plan.md +++ b/specs/remove-legacy-channelstate-messages/plan.md @@ -442,18 +442,31 @@ paginator/channel/client/thread suites. `yarn test-unit` (2583) + `yarn types` + **Dependencies:** Task 12 -**Status:** pending +**Status:** done (JS). React `CLAUDE.md` tracked separately on the React branch. -**Owner:** unassigned +**Owner:** claude **Scope:** - Remove/replace stale "use `channel.state.addMessageSorted()`/`removeMessage()`/`state.messages`" guidance; document `messagePaginator` as the source of truth. +**What landed (JS):** + +- Fixed the two stale JSDoc examples — `Channel.on` (`src/channel.ts`) and `StreamChat.on` + (`src/client.ts`) — that logged `channel.state.messages` → now `channel.messagePaginator.state.items`. +- Added a `CLAUDE.md` architecture note: messages / thread replies / pinned messages live in + `channel.messagePaginator` / `thread.messagePaginator` / `channel.pinnedMessagesPaginator` (single + source of truth), not `channel.state`; how to read/mutate; the read-only derived + `last_message_at`; pointer to `docs/breaking-changes-v14-v15.md`. +- `docs/breaking-changes-v14-v15.md` breaking-change ledger added earlier this effort. +- JS `CLAUDE.md` had no stale `addMessageSorted` guidance to remove. + **Acceptance Criteria:** -- [ ] No stale legacy-storage guidance in either `CLAUDE.md`. +- [x] No stale legacy-storage guidance in the JS `CLAUDE.md`. +- [ ] React `CLAUDE.md` (`DO NOT mutate channel.state.messages` / `addMessageSorted()`, "threads must + exist in main channel state") — remains; tracked on the React branch, not this one. --- diff --git a/specs/remove-legacy-channelstate-messages/state.json b/specs/remove-legacy-channelstate-messages/state.json index 9dff5d7f8..49c681f61 100644 --- a/specs/remove-legacy-channelstate-messages/state.json +++ b/specs/remove-legacy-channelstate-messages/state.json @@ -113,9 +113,10 @@ { "id": 14, "name": "Docs", - "status": "pending", - "owner": null, - "dependencies": [12] + "status": "done", + "owner": "claude", + "dependencies": [12], + "note": "JS side done. Fixed the two stale JSDoc examples (Channel.on / StreamChat.on) that logged channel.state.messages -> channel.messagePaginator.state.items; added a CLAUDE.md architecture note that messages/threads/pinned live in the paginators (channel.messagePaginator / thread.messagePaginator / channel.pinnedMessagesPaginator) as the single source of truth, not channel.state, incl. the read-only derived last_message_at, pointing at docs/breaking-changes-v14-v15.md (ledger added earlier). JS CLAUDE.md had no stale addMessageSorted guidance to remove. The React CLAUDE.md stale guidance ('DO NOT mutate channel.state.messages / use addMessageSorted()' + 'threads must exist in main channel state') remains and is tracked on the React branch, not here." }, { "id": 15, diff --git a/src/channel.ts b/src/channel.ts index a07e2eff8..8ffb2066f 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -2105,7 +2105,7 @@ export class Channel { /** * on - Listen to events on this channel. * - * channel.on('message.new', event => {console.log("my new message", event, channel.state.messages)}) + * channel.on('message.new', event => {console.log("my new message", event, channel.messagePaginator.state.items)}) * or * channel.on(event => {console.log(event.type)}) * diff --git a/src/client.ts b/src/client.ts index 783e3e95d..456bf5eda 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1188,7 +1188,7 @@ export class StreamChat { /** * on - Listen to events on all channels and users your watching * - * client.on('message.new', event => {console.log("my new message", event, channel.state.messages)}) + * client.on('message.new', event => {console.log("my new message", event, channel.messagePaginator.state.items)}) * or * client.on(event => {console.log(event.type)}) * From 0eef85ecae764c45f1a0abfa8fed3793c036dcb7 Mon Sep 17 00:00:00 2001 From: martincupela Date: Tue, 21 Jul 2026 14:59:51 +0200 Subject: [PATCH 20/25] refactor: remove unused MessageReplyPaginator --- .../paginators/MessageReplyPaginator.ts | 301 ------------------ src/pagination/paginators/index.ts | 1 - .../paginators/MessageReplyPaginator.test.ts | 114 ------- 3 files changed, 416 deletions(-) delete mode 100644 src/pagination/paginators/MessageReplyPaginator.ts delete mode 100644 test/unit/pagination/paginators/MessageReplyPaginator.test.ts diff --git a/src/pagination/paginators/MessageReplyPaginator.ts b/src/pagination/paginators/MessageReplyPaginator.ts deleted file mode 100644 index 415be87d5..000000000 --- a/src/pagination/paginators/MessageReplyPaginator.ts +++ /dev/null @@ -1,301 +0,0 @@ -import type { - AnyInterval, - Interval, - PaginationQueryParams, - PaginatorState, -} from './BasePaginator'; -import { isLogicalInterval, ZERO_PAGE_CURSOR } from './BasePaginator'; -import { - BasePaginator, - type PaginationQueryReturnValue, - type PaginationQueryShapeChangeIdentifier, - type PaginatorOptions, -} from './BasePaginator'; -import type { - LocalMessage, - MessagePaginationOptions, - PinnedMessagePaginationOptions, -} from '../../types'; -import type { Channel } from '../../channel'; -import { formatMessage, generateUUIDv4 } from '../../utils'; -import { makeComparator } from '../sortCompiler'; -import { isEqual } from '../../utils/mergeWith/mergeWithCore'; -import type { FieldToDataResolver } from '../types.normalization'; -import { resolveDotPathValue } from '../utility.normalization'; -import type { - JumpToMessageOptions, - MessagePaginatorOptions, - MessagePaginatorSort, -} from './MessagePaginator'; -import { ItemIndex } from '../ItemIndex'; - -export type MessageReplyPaginatorFilter = { - cid: string; - parent_id: string; -}; - -const DEFAULT_PAGE_SIZE = 50; - -const DEFAULT_BACKEND_SORT: MessagePaginatorSort = { - created_at: 1, -}; - -export type MessageReplyQueryShape = { - options: MessagePaginationOptions | PinnedMessagePaginationOptions; - sort: MessagePaginatorSort; -}; - -const getQueryShapeRelevantMessageOptions = ( - options: MessagePaginationOptions, -): Omit => { - const { - /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ - limit: _, - ...relevantOptions - } = options; - return relevantOptions; -}; - -const hasPaginationQueryShapeChanged: PaginationQueryShapeChangeIdentifier< - MessageReplyQueryShape -> = (prevQueryShape, nextQueryShape) => - !isEqual( - { - ...prevQueryShape, - options: getQueryShapeRelevantMessageOptions(prevQueryShape?.options ?? {}), - }, - { - ...nextQueryShape, - options: getQueryShapeRelevantMessageOptions(nextQueryShape?.options ?? {}), - }, - ); - -const dataFieldFilterResolver: FieldToDataResolver = { - matchesField: () => true, - resolve: (message, path) => resolveDotPathValue(message, path), -}; - -export type MessageReplyPaginatorOptions = Omit< - MessagePaginatorOptions, - 'paginatorOptions' -> & { - parentMessageId: string; - paginatorOptions?: PaginatorOptions; -}; - -export class MessageReplyPaginator extends BasePaginator< - LocalMessage, - MessageReplyQueryShape -> { - private readonly _id: string; - private channel: Channel; - protected _parentMessageId: string; - protected _sort = DEFAULT_BACKEND_SORT; - protected _nextQueryShape: MessageReplyQueryShape | undefined; - sortComparator: (a: LocalMessage, b: LocalMessage) => number; - - protected get intervalItemIdsAreHeadFirst(): boolean { - // Replies are stored in chronological order (created_at asc) within an interval. - // Pagination "head" (newest side) is therefore at the END of the `itemIds` array. - return false; - } - - protected get intervalSortDirection(): 'asc' | 'desc' { - // Head edge is newest, but sortComparator is created_at asc => newer head edges - // should come first => reverse interval ordering. - return 'desc'; - } - - constructor({ - channel, - id, - itemIndex = new ItemIndex({ getId: (item) => item.id }), - paginatorOptions, - parentMessageId, - }: MessageReplyPaginatorOptions) { - super({ - hasPaginationQueryShapeChanged, - initialCursor: ZERO_PAGE_CURSOR, - itemIndex, - ...paginatorOptions, - pageSize: paginatorOptions?.pageSize ?? DEFAULT_PAGE_SIZE, - }); - const definedSort = DEFAULT_BACKEND_SORT; - this.channel = channel; - this._parentMessageId = parentMessageId; - this._id = id ?? `message-reply-paginator-${generateUUIDv4()}`; - this._sort = definedSort; - this.sortComparator = makeComparator({ - sort: this._sort, - resolvePathValue: resolveDotPathValue, - tiebreaker: (l, r) => { - const leftId = this.getItemId(l); - const rightId = this.getItemId(r); - return leftId < rightId ? -1 : leftId > rightId ? 1 : 0; - }, - }); - this.setFilterResolvers([dataFieldFilterResolver]); - } - - get id() { - return this._id; - } - - get sort() { - return this._sort ?? DEFAULT_BACKEND_SORT; - } - - /** - * Even though we do not send filters object to the server, we need to have filters for client-side item ingestion logic. - */ - buildFilters = (): MessageReplyPaginatorFilter => ({ - cid: this.channel.cid, - parent_id: this._parentMessageId, - }); - - // invoked inside BasePaginator.executeQuery() to keep it as a query descriptor; - protected getNextQueryShape({ - direction, - }: PaginationQueryParams): MessageReplyQueryShape { - return { - options: { - limit: this.pageSize, - [direction === 'tailward' ? 'id_lt' : 'id_gt']: - direction && this.cursor?.[direction], - }, - sort: this._sort, - }; - } - - query = async ({ - direction, - queryShape, - }: PaginationQueryParams): Promise< - PaginationQueryReturnValue - > => { - if (!queryShape) { - queryShape = this.getNextQueryShape({ direction }); - } - const { sort, options } = queryShape; - let items: LocalMessage[]; - let tailward: string | undefined; - let headward: string | undefined; - if (this.config.doRequest) { - const result = await this.config.doRequest({ - options, - sort: Array.isArray(sort) ? sort : [sort], - }); - items = result?.items ?? []; - // if there is no direction, then we are jumping, and we want to set both directions in the cursor - tailward = - !direction || direction === 'tailward' - ? (result.cursor?.tailward ?? undefined) - : undefined; - headward = - !direction || direction === 'headward' - ? (result.cursor?.headward ?? undefined) - : undefined; - } else { - const { messages } = await this.channel.getReplies( - this._parentMessageId, - options, - Array.isArray(sort) ? sort : [sort], - ); - items = messages.map(formatMessage); - // if there is no direction, then we are jumping, and we want to set both directions in the cursor - tailward = !direction || direction === 'tailward' ? messages[0].id : undefined; - headward = - !direction || direction === 'headward' ? messages.slice(-1)[0].id : undefined; - } - - return { items, headward, tailward }; - }; - - isJumpQueryShape(queryShape: MessageReplyQueryShape): boolean { - return ( - !!queryShape?.options?.id_around || - !!(queryShape.options as MessagePaginationOptions)?.created_at_around - ); - } - - /** - * Jump to a message inside thread replies. - * - * Mirrors `MessagePaginator.jumpToMessage` behavior: - * - If the message is already present in the item index and belongs to an existing interval, - * it activates that interval without querying. - * - Otherwise, performs an `id_around` query and ensures the item is present. - */ - jumpToMessage = async ( - messageId: string, - { pageSize }: JumpToMessageOptions = {}, - ): Promise => { - let localMessage = this.getItem(messageId); - let interval: AnyInterval | undefined; - let state: Partial> | undefined; - - if (localMessage) { - interval = this.locateIntervalForItem(localMessage); - } - - if (!localMessage || !interval || isLogicalInterval(interval)) { - const result = await this.executeQuery({ - queryShape: { - options: { id_around: messageId, limit: pageSize }, - sort: this.sort, - }, - updateState: false, - }); - - localMessage = this.getItem(messageId); - if (!localMessage || !result || !result.targetInterval) { - this.channel.getClient().notifications.addError({ - message: 'Jump to message unsuccessful', - origin: { - emitter: 'MessageReplyPaginator.jumpToMessage', - context: { messageId, parentMessageId: this._parentMessageId }, - }, - options: { type: 'api:replies:query:failed' }, - }); - return false; - } - interval = result.targetInterval; - state = result.stateCandidate; - } - - if (!this.isActiveInterval(interval)) { - this.setActiveInterval(interval); - if (state) this.state.partialNext(state); - } - - return true; - }; - - jumpToTheLatestMessage = async (options?: JumpToMessageOptions): Promise => { - let latestMessageId: string | undefined; - const intervals = this.itemIntervals; - - if (!(intervals[0] as Interval)?.isHead) { - // get the first page (in case the pagination has not started at the head) - await this.executeQuery({ updateState: false }); - } - - const headInterval = intervals[0]; - if ((intervals[0] as Interval)?.isHead) { - latestMessageId = headInterval.itemIds.slice(-1)[0]; - } - - if (!latestMessageId) { - this.channel.getClient().notifications.addError({ - message: 'Jump to latest message unsuccessful', - origin: { emitter: 'MessageReplyPaginator.jumpToTheLatestMessage' }, - options: { type: 'api:message:replies:query:failed' }, - }); - return false; - } - - return await this.jumpToMessage(latestMessageId, options); - }; - - filterQueryResults = (items: LocalMessage[]) => items; -} diff --git a/src/pagination/paginators/index.ts b/src/pagination/paginators/index.ts index 217003249..aeaee4f4b 100644 --- a/src/pagination/paginators/index.ts +++ b/src/pagination/paginators/index.ts @@ -2,7 +2,6 @@ export * from './BasePaginator'; export * from './ChannelPaginator'; export { MessageIntervalPaginator } from './MessageIntervalPaginator'; export * from './MessagePaginator'; -export * from './MessageReplyPaginator'; export * from './PinnedMessagePaginator'; export * from './ReminderPaginator'; export * from './UserGroupPaginator'; diff --git a/test/unit/pagination/paginators/MessageReplyPaginator.test.ts b/test/unit/pagination/paginators/MessageReplyPaginator.test.ts deleted file mode 100644 index 43f73495c..000000000 --- a/test/unit/pagination/paginators/MessageReplyPaginator.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { MessageReplyPaginator } from '../../../../src/pagination/paginators/MessageReplyPaginator'; -import type { - LocalMessage, - MessagePaginationOptions, - MessageResponse, -} from '../../../../src/types'; - -const makeLocalMessage = (id: string, createdAtMs: number): LocalMessage => - ({ - attachments: [], - created_at: new Date(createdAtMs), - deleted_at: null, - id, - mentioned_users: [], - pinned_at: null, - reaction_groups: null, - status: 'received', - text: id, - type: 'regular', - updated_at: new Date(createdAtMs), - }) as LocalMessage; - -const makeChannel = () => - ({ - cid: 'messaging:cid', - getClient: () => ({ - notifications: { addError: vi.fn() }, - }), - // Not used when config.doRequest is provided - getReplies: vi.fn(), - }) as unknown as import('../../../../src/channel').Channel; - -describe('MessageReplyPaginator', () => { - it('jumpToMessage does not query if message already in an interval', async () => { - const channel = makeChannel(); - const paginator = new MessageReplyPaginator({ - channel, - parentMessageId: 'parent-1', - }); - - const doRequest = vi.fn(async (query) => { - const options = query.options as MessagePaginationOptions; - const ids = options.id_around ? ['m1'] : ['m1']; - return { - items: ids.map((id) => makeLocalMessage(id, 1)), - }; - }); - - paginator.config.doRequest = doRequest; - - // Seed intervals + index - await paginator.executeQuery({ - queryShape: { options: { limit: 1 }, sort: paginator.sort }, - }); - expect(doRequest).toHaveBeenCalledTimes(1); - - const executeSpy = vi.spyOn(paginator, 'executeQuery'); - const ok = await paginator.jumpToMessage('m1'); - expect(ok).toBe(true); - expect(executeSpy).not.toHaveBeenCalled(); - }); - - it('jumpToMessage queries id_around when message not present', async () => { - const channel = makeChannel(); - const paginator = new MessageReplyPaginator({ - channel, - parentMessageId: 'parent-1', - }); - - const doRequest = vi.fn(async () => { - return { - items: [makeLocalMessage('m2', 2)], - }; - }); - paginator.config.doRequest = doRequest; - - const ok = await paginator.jumpToMessage('m2', { pageSize: 10 }); - expect(ok).toBe(true); - - expect(doRequest).toHaveBeenCalledTimes(1); - expect(doRequest).toHaveBeenCalledWith({ - options: { id_around: 'm2', limit: 10 }, - sort: [{ created_at: 1 }], - }); - }); - - it('jumpToTheLatestMessage calls jumpToMessage with latest id from head interval', async () => { - const channel = makeChannel(); - const paginator = new MessageReplyPaginator({ - channel, - parentMessageId: 'parent-1', - }); - - const doRequest = vi.fn(async () => { - return { - items: [makeLocalMessage('m1', 1), makeLocalMessage('m2', 2)], - }; - }); - paginator.config.doRequest = doRequest; - - // Ensure intervals are populated - await paginator.executeQuery({ - queryShape: { options: { limit: 2 }, sort: paginator.sort }, - }); - - const jumpSpy = vi.spyOn(paginator, 'jumpToMessage'); - await paginator.jumpToTheLatestMessage(); - - // We don't hard assert the id here because interval "head" semantics are internal, - // but we ensure it uses jumpToMessage as the final step. - expect(jumpSpy).toHaveBeenCalled(); - }); -}); From 2846f0678cf3df08592b7b1aaa5bd3904862ece7 Mon Sep 17 00:00:00 2001 From: martincupela Date: Tue, 21 Jul 2026 15:04:01 +0200 Subject: [PATCH 21/25] docs: update the v14-v15 breaking-changes list --- docs/breaking-changes-v14-v15.md | 62 ++++++++++++++++++++++++++++++-- 1 file changed, 59 insertions(+), 3 deletions(-) diff --git a/docs/breaking-changes-v14-v15.md b/docs/breaking-changes-v14-v15.md index db19df652..97b74c2fb 100644 --- a/docs/breaking-changes-v14-v15.md +++ b/docs/breaking-changes-v14-v15.md @@ -1,8 +1,9 @@ # Breaking Changes: v14 → v15 (stream-chat JS) -Consumer-facing **breaking changes** introduced in the v15 line of the JS SDK on -`feat/message-paginator-master-merge` (beyond the master ⇄ PR merge itself). This is the source for -the v14 → v15 migration guide / release notes. Append newest at the top. Keep each entry +Consumer-facing **breaking changes** on the v15 line of the JS SDK — the `message-paginator` +initiative (`feat/message-paginator-master-merge` and the follow-on +`refactor/remove-legacy-channelstate-storage`), beyond the master ⇄ PR merge itself. This is the +source for the v14 → v15 migration guide / release notes. Append newest at the top. Keep each entry self-contained: what changed, before → after, how to migrate, why. > Scope note: this file tracks **public API / observable behavior** changes. Internal refactors with @@ -10,6 +11,61 @@ self-contained: what changed, before → after, how to migrate, why. --- +## `ChannelState` message / thread / pinned storage removed — paginators are the source of truth + +**Area:** `ChannelState`, `Channel`, `utils` · **Status:** implemented + +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` (via `client.threads` / the `Thread` object) +- **Pinned messages** → `channel.pinnedMessagesPaginator` + +### Removed from `ChannelState` + +Properties / getters: `messages`, `latestMessages`, `messageSets`, `messagePagination`, `threads`, +`pinnedMessages`. (Also `isUpToDate` and the `last_message_at` setter — see the dedicated entries +below.) + +Methods: `addMessageSorted`, `addMessagesSorted`, `removeMessage`, `findMessage`, +`findMessageByTimestamp`, `filterErrorMessages`, `loadMessageIntoState`, `clearMessages`, +`initMessages`, `pruneOldest`, `addReaction`, `removeReaction`, `updateUserMessages`, +`deleteUserMessages`, `addPinnedMessages`, `addPinnedMessage`, `removePinnedMessage`, +`removeQuotedMessageReferences` (plus the internal `_updateMessage` / `_updateQuotedMessageReferences` +/ `_add*`/`_remove*` reaction helpers). + +### Removed from `utils` (re-exported through the package root) + +`addToMessageList`, `messageSetPagination`, `binarySearchByDateEqualOrNearestGreater`, +`deleteUserMessages`, and the `MessageSet` / message-set pagination types. + +### Migrate + +| Before (v14) | After (v15) | +| ------------------------------------------------------------- | ------------------------------------------------------------------------------------ | +| `channel.state.messages` | `channel.messagePaginator.state.items` (reactive) / `channel.messagePaginator.items` | +| `channel.state.latestMessages` | `channel.messagePaginator.latestItems` / `.latestMessage` | +| `channel.state.messagePagination` | `channel.messagePaginator.state` (`hasMoreHead` / `hasMoreTail` / `cursor`) | +| `channel.state.threads[parentId]` | `thread.messagePaginator.state.items` (resolve the `Thread` via `client.threads`) | +| `channel.state.pinnedMessages` | `channel.pinnedMessagesPaginator.state.items` | +| `channel.state.addMessageSorted(m)` / `addMessagesSorted(ms)` | `channel.messagePaginator.ingestItem(m)` | +| `channel.state.removeMessage({ id })` | `channel.messagePaginator.removeItem({ id })` | +| `channel.state.findMessage(id)` | `channel.messagePaginator.getItem(id)` | + +Reactive reads use `useStateStore(channel.messagePaginator.state, …)` (or the paginator's `state` +store directly). Pin/unpin, reactions, user updates, and deletions are applied to the paginators by +the SDK's own event handlers — application code should not call the removed mutators. + +### Why + +Messages were previously stored twice — a flat `ChannelState` list plus message-set/pagination +bookkeeping — which had to be kept in sync with the paginators and could drift. Consolidating on the +paginators removes the dual-write, gives one API/behavior across the main list, threads, and pinned +messages (dedup-by-id, interval merge, `getItem`/`removeItem`, head-window semantics), and lets +`last_message_at` be derived rather than separately maintained. + ## `ChannelState.last_message_at` is now read-only (derived) **Area:** `ChannelState` · **Status:** implemented From 696fa2e0d7a5d41f9f574c9c8e5ee60aebfd04fe Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 22 Jul 2026 11:34:01 +0200 Subject: [PATCH 22/25] fix(pagination): sort null values to the tail regardless of direction --- src/pagination/sortCompiler.ts | 34 +++++++++++++---------- test/unit/pagination/sortCompiler.test.ts | 30 ++++++++++++++++++-- 2 files changed, 47 insertions(+), 17 deletions(-) diff --git a/src/pagination/sortCompiler.ts b/src/pagination/sortCompiler.ts index b4b05aaf9..e775dbcb1 100644 --- a/src/pagination/sortCompiler.ts +++ b/src/pagination/sortCompiler.ts @@ -155,21 +155,27 @@ export function makeComparator< case 'boolean': comparison = compare(normalized.a, normalized.b); break; - default: - // deterministic fallback: null/undefined last; else string compare - if (leftValue == null && rightValue == null) comparison = 0; - else if (leftValue == null) comparison = 1; - else if (rightValue == null) comparison = -1; - else { - const stringLeftValue = String(leftValue), - stringRightValue = String(rightValue); - comparison = - stringLeftValue === stringRightValue - ? 0 - : stringLeftValue < stringRightValue - ? -1 - : 1; + default: { + // Null/undefined always sort to the tail, INDEPENDENT of `direction`. Return here so the + // result bypasses the direction flip below — otherwise a descending sort (e.g. + // `{ last_message_at: -1 }`) negates "null last" into "null first" and floats value-less + // items (e.g. channels with no last message) to the head of the list. + if (leftValue == null && rightValue == null) { + comparison = 0; // tie on this term; fall through to the next term / tiebreaker + break; } + if (leftValue == null) return 1; // a (null) sorts after b + if (rightValue == null) return -1; // b (null) sorts after a + // Both non-null but not normalizable: deterministic string compare (respects direction). + const stringLeftValue = String(leftValue), + stringRightValue = String(rightValue); + comparison = + stringLeftValue === stringRightValue + ? 0 + : stringLeftValue < stringRightValue + ? -1 + : 1; + } } if (comparison !== 0) return direction === 1 ? comparison : -comparison; } diff --git a/test/unit/pagination/sortCompiler.test.ts b/test/unit/pagination/sortCompiler.test.ts index ccc03a21b..34c56191a 100644 --- a/test/unit/pagination/sortCompiler.test.ts +++ b/test/unit/pagination/sortCompiler.test.ts @@ -140,7 +140,7 @@ describe('makeComparator', () => { expect(orderByComparator(items, cmp)).toEqual(['1', '2', '3', '4']); }); - it('fallback ordering: null/undefined come last (ascending) and first (descending)', () => { + it('fallback ordering: null/undefined come last regardless of direction', () => { const items: Item[] = [ { cid: 'a', v: 10 }, { cid: 'b', v: undefined }, @@ -148,11 +148,35 @@ describe('makeComparator', () => { { cid: 'd', v: 5 }, ]; + // null/undefined always sort to the tail; the direction only orders the real values. const asc = toComparator({ v: 1 }); - expect(orderByComparator(items, asc)).toEqual(['d', 'a', 'b', 'c']); // null/undefined last + expect(orderByComparator(items, asc)).toEqual(['d', 'a', 'b', 'c']); const desc = toComparator({ v: -1 }); - expect(orderByComparator(items, desc)).toEqual(['b', 'c', 'a', 'd']); // null/undefined first + expect(orderByComparator(items, desc)).toEqual(['a', 'd', 'b', 'c']); + }); + + it('keeps null date values at the tail for a descending sort (last_message_at regression)', () => { + // Reproduces the channel-list bug: channels with no last_message_at must sort to the BOTTOM of a + // `{ last_message_at: -1 }` list, not float to the top. Before the fix, the "null last" result + // was negated by the descending direction flip and value-less channels were prepended at the head. + type Chan = { cid: string; last_message_at: string | null }; + const chans: Chan[] = [ + { cid: 'old', last_message_at: '2022-01-01T00:00:00.000Z' }, + { cid: 'none1', last_message_at: null }, + { cid: 'new', last_message_at: '2026-07-21T00:00:00.000Z' }, + { cid: 'none2', last_message_at: null }, + ]; + const desc = makeComparator>({ + sort: { last_message_at: -1 }, + resolvePathValue: defaultResolvePathValue, + }); + expect([...chans].sort(desc).map((c) => c.cid)).toEqual([ + 'new', + 'old', + 'none1', + 'none2', + ]); }); it('applies custom tiebreaker when provided', () => { From 8bc92437c8903f859252aa7db8ee8a4338ac8b9f Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 22 Jul 2026 11:35:35 +0200 Subject: [PATCH 23/25] refactor(pagination): unify Reminder/UserGroup/Channel paginators on interval storage and replace channel.state.last_message_at with messagePaginator.aggregateState --- CLAUDE.md | 2 +- docs/breaking-changes-v14-v15.md | 57 +++-- src/channel.ts | 17 +- src/channel_state.ts | 14 -- src/pagination/paginators/BasePaginator.ts | 62 ++---- src/pagination/paginators/ChannelPaginator.ts | 11 +- .../paginators/MessageIntervalPaginator.ts | 123 +---------- src/pagination/paginators/MessagePaginator.ts | 133 +++++++++++- .../paginators/ReminderPaginator.ts | 34 ++- .../paginators/UserGroupPaginator.ts | 14 +- src/thread.ts | 8 + test/unit/channel.test.js | 42 ++-- .../paginators/BasePaginator.test.ts | 141 +++++++------ .../paginators/ChannelPaginator.test.ts | 141 +++++++++++-- .../paginators/MessagePaginator.test.ts | 198 ++++++++++++------ .../paginators/ReminderPaginator.test.ts | 97 +++++++++ .../paginators/UserGroupPaginator.test.ts | 121 +++++++++++ test/unit/threads.test.ts | 13 ++ 18 files changed, 847 insertions(+), 381 deletions(-) create mode 100644 test/unit/pagination/paginators/ReminderPaginator.test.ts create mode 100644 test/unit/pagination/paginators/UserGroupPaginator.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index c7a34c0e9..c565ff036 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,7 +52,7 @@ This is a single-package SDK with **no monorepo**. The public surface is everyth ### Module map of `src/` - **`client.ts` — `StreamChat` facade.** ~5k-line class. Prefer `StreamChat.getInstance(key, secret?, options?)` — the constructor exists for advanced uses but `getInstance` is what `connectUser` warnings and most docs assume. Owns: the axios instance, WS connection lifecycle, `TokenManager`, and a registry of subsystem managers (`threads`, `polls`, `notifications`, `reminders`, `moderation`, `uploadManager`, `messageDeliveryReporter`, plus an optional `offlineDb` injected via `setOfflineDBApi`). New REST endpoints are added here as methods that call `axiosInstance` and return a type from `types.ts`. -- **`channel.ts` (~2.5k lines) + `channel_state.ts` (~1.1k) + `channel_manager.ts` + `channel_batch_updater.ts`** — per-channel object, its in-memory state, and the manager that orchestrates collections of channels (query/sort/filter, pagination, archived/pinned handling). **Messages are NOT stored on `channel.state`.** The message list, thread replies, and pinned messages each live in a paginator — `channel.messagePaginator`, `thread.messagePaginator`, and `channel.pinnedMessagesPaginator` — which are the single source of truth (interval storage + a canonical `ItemIndex`). Read them via `channel.messagePaginator.state.items` / `.getItem(id)` / `.latestMessage`, and mutate via the paginator (`ingestItem` / `removeItem`), never a legacy `channel.state.addMessageSorted()` / `state.messages` (removed). `channel.state.last_message_at` is a read-only getter derived from `channel.messagePaginator.latestMessage`. See `docs/breaking-changes-v14-v15.md`. +- **`channel.ts` (~2.5k lines) + `channel_state.ts` (~1.1k) + `channel_manager.ts` + `channel_batch_updater.ts`** — per-channel object, its in-memory state, and the manager that orchestrates collections of channels (query/sort/filter, pagination, archived/pinned handling). **Messages are NOT stored on `channel.state`.** The message list, thread replies, and pinned messages each live in a paginator — `channel.messagePaginator`, `thread.messagePaginator`, and `channel.pinnedMessagesPaginator` — which are the single source of truth (interval storage + a canonical `ItemIndex`). Read them via `channel.messagePaginator.state.items` / `.getItem(id)` / `.latestItem` (newest loaded item), and mutate via the paginator (`ingestItem` / `removeItem`), never a legacy `channel.state.addMessageSorted()` / `state.messages` (removed). `channel.state.last_message_at` was **removed**; the channel's latest-message timestamp lives on `channel.messagePaginator.lastMessageAt` (its `aggregateState` store — seeded from `ChannelResponse.last_message_at`, then advanced monotonically as messages are ingested). See `docs/breaking-changes-v14-v15.md`. - **`connection.ts` (`StableWSConnection`) + `connection_fallback.ts` (`WSConnectionFallback`)** — realtime transport. Primary WS implementation does its own 25s ping / 35s health-check loop and reconnects on close/error/offline events; the fallback long-polls over HTTP. The client picks between them based on first-connect outcome; both emit `connection.changed` / `transport.changed` events into the client's local event bus. - **`store.ts` — `StateStore`.** Reactive primitive (see "State and subscription patterns" below). - **`signing.ts` — webhook + token helpers.** Server-side primitives `verifyAndParseWebhook`, `parseSqs`, `parseSns`, `verifySignature` (recent CHA-3071 added compressed-payload support). These are re-exported through `client.ts`. **The HMAC is always computed over the uncompressed JSON bytes** — gzip detection uses the `1f 8b` magic bytes, not headers, so the same handler works whether your platform middleware auto-decompressed or not. `CheckSignature` is deprecated in favor of `verifySignature` purely to fix parameter order; new code should use `verifySignature(body, signature, secret)`. diff --git a/docs/breaking-changes-v14-v15.md b/docs/breaking-changes-v14-v15.md index 97b74c2fb..dd83f0fb5 100644 --- a/docs/breaking-changes-v14-v15.md +++ b/docs/breaking-changes-v14-v15.md @@ -66,22 +66,53 @@ paginators removes the dual-write, gives one API/behavior across the main list, messages (dedup-by-id, interval merge, `getItem`/`removeItem`, head-window semantics), and lets `last_message_at` be derived rather than separately maintained. -## `ChannelState.last_message_at` is now read-only (derived) +## `ChannelState.last_message_at` removed — use `channel.messagePaginator.lastMessageAt` **Area:** `ChannelState` · **Status:** implemented -`channel.state.last_message_at` is now a **read-only getter** derived from the message paginator's -tracked latest message (`channel.messagePaginator.latestMessage?.created_at ?? null`). The writable -setter and its backing field were removed. - -- **Before:** `channel.state.last_message_at = someDate` (writable). Internally maintained by the - now-removed `Channel._trackLatestMessage`. -- **After:** read-only. It reflects whatever the message paginator holds as its latest message. -- **Migrate:** don't assign it. To make a channel's `last_message_at` reflect a message, ingest / - track that message on `channel.messagePaginator` (the message source of truth). Assigning to it is - a no-op (silently ignored / throws in strict mode) and a TypeScript error. -- **Why:** the message paginator is the single source of truth for messages; `last_message_at` is a - projection of it. A separate writable field could drift from the actual latest message. +`channel.state.last_message_at` was **removed entirely** (it was briefly a read-only getter earlier +in v15; that getter is gone too). The channel's latest-message timestamp is now owned by the message +paginator as a whole-collection aggregate. + +- **Before:** `channel.state.last_message_at` (writable, then a derived getter). Internally + maintained by the now-removed `Channel._trackLatestMessage`. +- **After:** `channel.messagePaginator.lastMessageAt` — a `Date | null` **derived** getter over the + paginator's `aggregateState` store (`MessagePaginatorAggregateState = { lastMessage, +seededLastMessageAt }`). It returns `max(lastMessage?.created_at, seededLastMessageAt)`: + `lastMessage` is the newest loaded/received message (advanced on ingest), `seededLastMessageAt` is + the server floor **seeded from `ChannelResponse.last_message_at`** (for channels whose newest + message isn't loaded). Deriving the sort key from the two independent facts means it can never drift + from the display message. Subscribe to `channel.messagePaginator.aggregateState` for reactivity. +- **Migrate:** replace `channel.state.last_message_at` reads with + `channel.messagePaginator.lastMessageAt`. It is not writable; the value is derived from ingested + messages and the server seed. +- **Why:** the message paginator is the single source of truth for messages; `last_message_at` is an + aggregate over them (the dual of pagination). Deriving it through a `ChannelState` getter that + reached into the paginator's message index risked stale/mixed-basis sorting (a seeded-but-stale + paginator preferred over a fresher server value); a single seeded-then-advanced value on the + paginator removes that hazard. +- **Tracking relocated:** `MessageIntervalPaginator`'s `state.latestMessageId` and the `latestMessage` + getter (id resolved from the pagination `state`) were replaced. The tracked latest now lives on + `MessagePaginator.aggregateState.lastMessage`, advanced on every ingest. + `MessagePaginator.latestMessage` remains as a convenience getter but now reads `aggregateState`. + This matters for reactivity: pagination `state` only emits when the **active** interval is impacted, + so a WS message landing in the (non-active) head interval would not notify a `state`-derived + latest; `aggregateState` is written directly on each advance and emits regardless — subscribe to it + (e.g. for a channel/thread list item's latest-message display). + +## `PaginatorState.headItems` and `BasePaginator.headItems` removed — use `latestItems` / `latestItem` + +**Area:** `BasePaginator` · **Status:** implemented + +The reactive `headItems` field on `PaginatorState` and the `paginator.headItems` getter were removed. +They materialized a reference-stable copy of the newest-loaded window into pagination state, but had +no consumer. + +- **Migrate:** use the computed getters `paginator.latestItems` (newest loaded window — returns `[]` + instead of `undefined` before the first load) and `paginator.latestItem` (single newest loaded + item). These are derived directly from the intervals and are unchanged. +- **Why:** dead state. `latestItems`/`latestItem` already provide the value, computed from the + intervals; the materialized mirror only added a preprocessor and a `PaginatorState` field. ## `ChannelState.isUpToDate` / `setIsUpToDate` removed diff --git a/src/channel.ts b/src/channel.ts index 8ffb2066f..e50ffbbdd 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -2788,15 +2788,12 @@ export class Channel { this.state.membership = state.membership || {}; - // The main message list is seeded into channel.messagePaginator (see Channel.query / - // client.hydrateActiveChannels), whose ingestion advances the tracked latest message - // (→ last_message_at). Re-assert it here from the response for the one path where the paginator - // seed is skipped: an already-loaded channel that the viewer has jumped away from (see - // client.hydrateActiveChannels), where re-seeding would clobber their window. Monotonic, so this - // is a no-op when the seed already advanced past these messages. - (state.messages || []).forEach((message) => - this.messagePaginator.trackLatestMessage(formatMessage(message)), - ); + // Seed the message paginator's `lastMessageAt` aggregate from the server's authoritative + // `last_message_at`. The first-page seed (Channel.query / client.hydrateActiveChannels) also + // advances it from ingested messages; both feed the same monotonic max, so this additionally + // covers the path where the paginator seed is skipped (an already-loaded channel the viewer has + // jumped away from, where re-seeding would clobber their window). + this.messagePaginator.seedLastMessageAt(state.channel?.last_message_at); // Seed the pinned-messages paginator from the same response. this.pinnedMessagesPaginator.seedFirstPageSync( @@ -2824,7 +2821,7 @@ export class Channel { // that everything up to this point is not marked as unread const readUpdates: ChannelState['read'] = {}; if (userID != null) { - const last_read = this.state.last_message_at || new Date(); + const last_read = this.messagePaginator.lastMessageAt || new Date(); if (user) { readUpdates[user.id] = { user, diff --git a/src/channel_state.ts b/src/channel_state.ts index 4771755fe..e61a4bff5 100644 --- a/src/channel_state.ts +++ b/src/channel_state.ts @@ -105,20 +105,6 @@ export class ChannelState { this.membersStore.partialNext({ memberCount }); } - /** - * Timestamp of the channel's latest message, derived from the message paginator's tracked latest - * message (`channel.messagePaginator.latestMessage`), or `null` when nothing is tracked. Read by - * `ChannelPaginator` to sort the channel list. - * - * Read-only: `last_message_at` is a projection of the message paginator (the single source of - * truth for messages). Advance it by ingesting/tracking a message on `channel.messagePaginator`, - * not by assignment. (Removing the former writable setter is a breaking change — see - * `docs/breaking-changes-v14-v15.md`.) - */ - get last_message_at(): Date | null { - return this._channel?.messagePaginator?.latestMessage?.created_at ?? null; - } - get read() { return this.readStore.getLatestValue().read; } diff --git a/src/pagination/paginators/BasePaginator.ts b/src/pagination/paginators/BasePaginator.ts index 0990c32a4..52bac95ce 100644 --- a/src/pagination/paginators/BasePaginator.ts +++ b/src/pagination/paginators/BasePaginator.ts @@ -244,13 +244,6 @@ export type PaginatorState = { hasMoreTail: boolean; isLoading: boolean; items: T[] | undefined; - /** - * The newest loaded window of items, kept in sync with {@link BasePaginator.latestItems} - * regardless of which window is currently *active* (`items` follows the active interval, which may - * point at a jumped-to / searched window). Reactive mirror for UI that needs to react to changes in - * the latest item / latest messages; `undefined` until the first load (mirrors `items`). - */ - headItems?: T[] | undefined; lastQueryError?: Error; cursor?: PaginatorCursor; offset?: number; @@ -464,33 +457,6 @@ export abstract class BasePaginator { this._filterFieldToDataResolvers = []; this._usesItemIntervalStorage = !!itemIndex; this._itemIndex = itemIndex ?? new ItemIndex({ getId: this.getItemId.bind(this) }); - - // Materialize the reactive `headItems` (newest-loaded window) on every state emission. There is - // no single items-emission choke point, so a preprocessor keeps it in sync generically. It runs - // before subscribers are notified and mutates the incoming value. - this.state.addPreprocessor((nextValue, prevValue) => { - // `undefined` until something is loaded (mirrors `items`). In interval mode the head window is - // derived from the intervals (already mutated by the time state is emitted), so it reflects the - // newest window even when the active window is a jumped-to older interval. In flat mode the head - // window IS the full list — read `nextValue.items` (NOT `this.items`, which still holds the - // previous emitted value inside a preprocessor). - const candidate = - typeof nextValue.items === 'undefined' - ? undefined - : this.usesItemIntervalStorage - ? this.latestItems - : nextValue.items; - const previous = prevValue?.headItems; - // Preserve the previous array reference when the window is unchanged (per-index identity) so a - // `headItems` selector does not re-fire on unrelated state changes (isLoading, cursor, ...). - nextValue.headItems = - candidate && - previous && - candidate.length === previous.length && - candidate.every((item, index) => item === previous[index]) - ? previous - : candidate; - }); } // --------------------------------------------------------------------------- @@ -550,7 +516,6 @@ export abstract class BasePaginator { hasMoreTail: true, isLoading: false, items: undefined, - headItems: undefined, lastQueryError: undefined, cursor: this.config.initialCursor, offset: this.config.initialOffset ?? 0, @@ -561,15 +526,6 @@ export abstract class BasePaginator { return this.state.getLatestValue().items; } - /** - * Reactive newest-loaded window, materialized on {@link PaginatorState}. Equivalent to - * {@link BasePaginator.latestItems} (`undefined` instead of `[]` before the first load) but - * subscribable via `state` for UI that reacts to latest-item / latest-messages changes. - */ - get headItems() { - return this.state.getLatestValue().headItems; - } - /** * The newest loaded window of items, independent of which window is currently *active* * (`items` follows the active interval, which may point at a jumped-to / searched window). In @@ -1991,10 +1947,20 @@ export abstract class BasePaginator { }; protected getStateBeforeFirstQuery(): PaginatorState { - return { + const state: PaginatorState = { ...this.initialState, isLoading: true, }; + // This is the one moment the loaded window is (re)established from its start offset. For offset + // pagination the head (beginning) is loaded exactly when that window starts at offset 0, so + // hasMoreHead is a constant known before the query runs — anchor it here, once. It must NOT be + // re-derived per page in postQueryReconcile, because the offset only grows tailward from here and + // would then read as "more headward" even for a list that started at the head. Cursor pagination + // learns hasMoreHead from the query response, so leave the optimistic default for it. + if (!this.isCursorPagination) { + state.hasMoreHead = (this.config.initialOffset ?? 0) > 0; + } + return state; } // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -2242,7 +2208,11 @@ export abstract class BasePaginator { } } else { // todo: we could keep the offset in two directions (initial tailward offset would be taken from config.initialOffset) - stateUpdate.offset = (this.offset ?? 0) + items.length; + const startOffset = this.offset ?? 0; + stateUpdate.offset = startOffset + items.length; + // Only hasMoreTail depends on the page result. hasMoreHead is fixed by where the loaded window + // starts (offset 0 => head loaded) and was anchored once at the reset (getStateBeforeFirstQuery); + // the offset only grows tailward from here, so leave hasMoreHead untouched. stateUpdate.hasMoreTail = items.length === this.pageSize; } diff --git a/src/pagination/paginators/ChannelPaginator.ts b/src/pagination/paginators/ChannelPaginator.ts index 603ffdf23..97d1796b0 100644 --- a/src/pagination/paginators/ChannelPaginator.ts +++ b/src/pagination/paginators/ChannelPaginator.ts @@ -10,6 +10,7 @@ import { BasePaginator } from './BasePaginator'; import type { FilterBuilderOptions } from '../FilterBuilder'; import { FilterBuilder } from '../FilterBuilder'; import { makeComparator } from '../sortCompiler'; +import { ItemIndex } from '../ItemIndex'; import { generateUUIDv4 } from '../../utils'; import type { StreamChat } from '../../client'; import type { Channel } from '../../channel'; @@ -111,7 +112,7 @@ const lastUpdatedFilterResolver: FieldToDataResolver = { matchesField: (field) => field === 'last_updated', resolve: (channel) => { // combination of last_message_at and updated_at - const lastMessageAt = channel.state.last_message_at?.getTime() ?? null; + const lastMessageAt = channel.messagePaginator.lastMessageAt?.getTime() ?? null; const updatedAt = channel.data?.updated_at ? new Date(channel.data?.updated_at).getTime() : undefined; @@ -170,7 +171,7 @@ const dataFieldFilterResolver: FieldToDataResolver = { const channelSortPathResolver: PathResolver = (channel, path) => { switch (path) { case 'last_message_at': - return channel.state.last_message_at; + return channel.messagePaginator.lastMessageAt; case 'has_unread': { return hasUnreadFilterResolver.resolve(channel, path); } @@ -211,7 +212,11 @@ export class ChannelPaginator extends BasePaginator requestOptions, sort, }: ChannelPaginatorOptions) { - super({ hasPaginationQueryShapeChanged, ...paginatorOptions }); + super({ + hasPaginationQueryShapeChanged, + itemIndex: new ItemIndex({ getId: (channel) => channel.cid }), + ...paginatorOptions, + }); const definedSort = sort ?? DEFAULT_BACKEND_SORT; this.client = client; this._id = id ?? `channel-paginator-${generateUUIDv4()}`; diff --git a/src/pagination/paginators/MessageIntervalPaginator.ts b/src/pagination/paginators/MessageIntervalPaginator.ts index 22a6f4a7d..b8068e333 100644 --- a/src/pagination/paginators/MessageIntervalPaginator.ts +++ b/src/pagination/paginators/MessageIntervalPaginator.ts @@ -86,17 +86,7 @@ const DEFAULT_BACKEND_SORT: MessagePaginatorSort = { // server's default size is 100 const DEFAULT_CHANNEL_MESSAGE_LIST_PAGE_SIZE = 100; -export type MessagePaginatorState = PaginatorState & { - /** - * Id of the newest message tracked for `channel.state.last_message_at` derivation. Advanced by - * {@link MessageIntervalPaginator.trackLatestMessage} (monotonic by `created_at`, honoring the - * per-paginator {@link MessageIntervalPaginator.shouldAdvanceLatestMessage} predicate — e.g. the main - * list skips system messages when configured and thread-only replies always), resolved back to a - * message via the {@link MessageIntervalPaginator.latestMessage} getter (index lookup), and reset - * to `null` with the rest of the state on {@link MessageIntervalPaginator.clearStateAndCache}. - */ - latestMessageId?: string | null; -}; +export type MessagePaginatorState = PaginatorState; export type MessageQueryShape = MessagePaginationOptions | PinnedMessagePaginationOptions; /** @@ -114,7 +104,7 @@ const dataFieldFilterResolver: FieldToDataResolver = { resolve: (message, path) => resolveDotPathValue(message, path), }; -const getMessageCreatedAtTimestamp = (message: LocalMessage): number | null => { +export const getMessageCreatedAtTimestamp = (message: LocalMessage): number | null => { if (!(message.created_at instanceof Date)) return null; const timestamp = message.created_at.getTime(); return Number.isFinite(timestamp) ? timestamp : null; @@ -168,105 +158,6 @@ export class MessageIntervalPaginator extends BasePaginator< return !message.shadowed; } - /** - * Source of truth for the tracked latest message. Kept as an instance field rather than only in - * `state` so it can be advanced from inside `ingestPage`/`setItems` (which run within a - * `state.next` updater, where a nested `partialNext` would be clobbered by the outer update). A - * state preprocessor mirrors this into {@link MessagePaginatorState.latestMessageId} on every - * emission, so subscribers stay reactive. - */ - private _latestMessageId: string | null = null; - - get initialState(): MessagePaginatorState { - return { ...super.initialState, latestMessageId: null }; - } - - /** - * The newest message of this list, resolved from the item index by the tracked id. Deliberately - * index-resolved rather than derived from the head window, so it is unaffected by which window is - * active OR by the interval/item sort orientation, and stays current across edits/reactions. This - * is the source of truth for `channel.state.last_message_at`. Returns `undefined` once nothing is - * tracked or the tracked message is no longer in the index. - */ - get latestMessage(): LocalMessage | undefined { - return this._latestMessageId ? this.getItem(this._latestMessageId) : undefined; - } - - /** - * Whether `message` may advance the tracked latest message. Base excludes only shadowed messages; - * subclasses narrow it to their notion of "the latest message of this list" (e.g. - * {@link MessagePaginator} additionally excludes thread-only replies and — when configured — - * system messages, but only for the main channel list). - */ - protected shouldAdvanceLatestMessage(message: LocalMessage): boolean { - return !message.shadowed; - } - - /** - * Monotonically advance the tracked latest message id to `message` when it is newer (by - * `created_at`) than the currently tracked one and {@link MessageIntervalPaginator.shouldAdvanceLatestMessage} - * permits it. Updates only the instance field (no state write) so it is safe to call from inside a - * `state.next` updater; the mirror preprocessor reflects it on the next emission. Returns `true` - * when the pointer moved. - * - * The current latest's timestamp is resolved from the item index rather than cached: there is no - * interval trimming, so the tracked message stays resolvable while loaded; the only way it leaves - * the index is deletion (its author hard-deleted / an explicit remove), and in that case advancing - * to the next newest loaded message is the correct outcome, not a regression. - */ - protected advanceLatestTo(message: LocalMessage): boolean { - if (!this.shouldAdvanceLatestMessage(message)) return false; - const incomingTimestamp = getMessageCreatedAtTimestamp(message); - if (incomingTimestamp === null) return false; - const current = this.latestMessage; - const currentTimestamp = current ? getMessageCreatedAtTimestamp(current) : null; - if (currentTimestamp !== null && incomingTimestamp <= currentTimestamp) return false; - this._latestMessageId = message.id; - return true; - } - - /** - * Explicitly track `message` as the newest message of this list, for callers that advance it - * without a normal in-window ingest — the channel-open seed reconciliation (`_initializeState`, - * whose paginator seed is skipped for an already-loaded channel jumped to an older window) and - * offline pending-message replay. The message is upserted into the item index so - * {@link MessageIntervalPaginator.latestMessage} can resolve it. Advancement is monotonic (see - * {@link MessageIntervalPaginator.advanceLatestTo}). - * - * Intentionally does NOT emit: the field is the source of truth (read synchronously by - * `latestMessage` / `channel.state.last_message_at`), and the mirror preprocessor publishes it into - * `state.latestMessageId` on the next emission. - */ - trackLatestMessage(message: LocalMessage) { - if (!this.advanceLatestTo(message)) return; - this._itemIndex.setOne(message); - } - - ingestItem(item: LocalMessage): boolean { - // Advance BEFORE delegating so `super.ingestItem`'s own emission already reflects the new latest - // (the mirror preprocessor reads the field). Only items that survive the filter are tracked - - // `ingestItem` also handles removals of items that no longer match. The field-only advance does - // not touch the index, so it cannot confuse `super`'s new-vs-existing item diffing, and it reads - // the *previous* latest (still indexed) for the monotonic comparison. - if (this.matchesFilter(item)) { - this.advanceLatestTo(item); - } - return super.ingestItem(item); - } - - ingestPage( - params: Parameters['ingestPage']>[0], - ): Interval | null { - const interval = super.ingestPage(params); - // Advance AFTER delegating: page items reference each other by index for the monotonic - // comparison, and they are only in the index once `super.ingestPage` has run. The committing - // emission (this method's caller - `setItems` / query / `mergeNewestPage`) mirrors the field. - if (params.page?.length) { - for (const item of params.page) this.advanceLatestTo(item); - } - return interval; - } - protected get intervalItemIdsAreHeadFirst(): boolean { // Messages are stored in chronological order (created_at asc) within an interval. // Pagination "head" (newest side) is therefore at the END of the `itemIds` array. @@ -326,14 +217,6 @@ export class MessageIntervalPaginator extends BasePaginator< }, }); this.setFilterResolvers([dataFieldFilterResolver]); - - // Mirror the tracked-latest source-of-truth field into reactive state on every emission. Doing - // it in a preprocessor (rather than a `partialNext` at each advance) means advances made from - // inside a `state.next` updater - `ingestPage` via `setItems`/query - are reflected by the very - // update that commits the page, instead of being clobbered by it. - this.state.addPreprocessor((nextValue) => { - nextValue.latestMessageId = this._latestMessageId; - }); } get id() { @@ -812,8 +695,6 @@ export class MessageIntervalPaginator extends BasePaginator< }; clearStateAndCache() { - // Clear the source-of-truth field first so the `resetState` emission mirrors `null`. - this._latestMessageId = null; this.resetState(); this._itemIndex.clear(); this.clearMessageFocusSignal(); diff --git a/src/pagination/paginators/MessagePaginator.ts b/src/pagination/paginators/MessagePaginator.ts index f9c27f518..0f83fa64e 100644 --- a/src/pagination/paginators/MessagePaginator.ts +++ b/src/pagination/paginators/MessagePaginator.ts @@ -1,6 +1,11 @@ -import type { ExecuteQueryReturnValue, PostQueryReconcileParams } from './BasePaginator'; +import type { + ExecuteQueryReturnValue, + Interval, + PostQueryReconcileParams, +} from './BasePaginator'; import { type MessagePaginatorOptions as BaseMessagePaginatorOptions, + getMessageCreatedAtTimestamp, type JumpToMessageOptions, MessageIntervalPaginator, type MessageQueryShape, @@ -20,6 +25,33 @@ export type { } from './MessageIntervalPaginator'; export { MessageIntervalPaginator } from './MessageIntervalPaginator'; +/** + * Auxiliary (non-pagination) state for the message paginator: whole-collection aggregates that are + * independent of the active pagination window (the dual of pagination — values over the entire set, + * not a page of it). `lastMessageAt` is effectively `MAX(created_at)` over the channel-relevant + * messages and is the source of truth for channel-list ordering + * (`channel.messagePaginator.lastMessageAt`): seeded from `ChannelResponse.last_message_at`, then + * advanced monotonically as newer messages are ingested. + */ +export type MessagePaginatorAggregateState = { + /** + * The newest channel-relevant message loaded/received (monotonic by `created_at`), for display — + * e.g. a thread-list item's latest-reply avatar. `null` until one is ingested. Lives here, NOT + * derived from pagination `state`, so it stays reactive when a WS message lands in the head interval + * while an older window is active — the pagination store only emits when the *active* interval is + * impacted (see `BasePaginator.ingestItem`), so a `state`-derived latest would go stale in that case. + */ + lastMessage: LocalMessage | null; + /** + * Server-provided `ChannelResponse.last_message_at` floor, for channels whose newest message is not + * loaded (e.g. surfaced by a channel-list query). Kept SEPARATE from {@link lastMessage} so it can + * outrank a stale/absent loaded message for sorting without overwriting the display message. The + * sort key {@link MessagePaginator.lastMessageAt} is derived as the max of the two, so the two can + * never drift out of sync. + */ + seededLastMessageAt: Date | null; +}; + export type MessagePaginatorOptions = BaseMessagePaginatorOptions & { /** * Controls whether `jumpToTheFirstUnreadMessage()` should prefer the `unreadStateSnapshot` @@ -77,6 +109,13 @@ export class MessagePaginator extends MessageIntervalPaginator { * this store for reactivity, or read the current boolean directly via the {@link isViewingLive} getter. */ readonly liveViewState: StateStore; + /** + * Auxiliary (non-pagination) state — see {@link MessagePaginatorAggregateState}. A store separate + * from `state` so `lastMessageAt` can be advanced from inside a `state.next` updater + * (`ingestPage`) without being clobbered, and so consumers subscribe to a quiet signal that only + * emits when the aggregate actually changes (not on every scroll/pagination emission). + */ + readonly aggregateState: StateStore; constructor({ unreadReferencePolicy = 'snapshot', @@ -93,6 +132,34 @@ export class MessagePaginator extends MessageIntervalPaginator { this.liveViewState = new StateStore({ isViewingLive: false, }); + this.aggregateState = new StateStore({ + lastMessage: null, + seededLastMessageAt: null, + }); + } + + /** + * Channel-list sort key: the later of the newest loaded message's `created_at` and the server seed. + * **Derived** (never stored) so it cannot drift from {@link latestMessage}. `null` until seeded or a + * message is ingested. + */ + get lastMessageAt(): Date | null { + const { lastMessage, seededLastMessageAt } = this.aggregateState.getLatestValue(); + const fromMessage = + lastMessage?.created_at instanceof Date ? lastMessage.created_at : null; + if (fromMessage && seededLastMessageAt) { + return fromMessage >= seededLastMessageAt ? fromMessage : seededLastMessageAt; + } + return fromMessage ?? seededLastMessageAt; + } + + /** + * The newest channel-relevant message (the monotonic tracked latest), for display. Convenience read + * of {@link aggregateState}; subscribe to `aggregateState` for reactivity. `null` until a message is + * ingested (a server-only seed advances {@link lastMessageAt} but leaves this `null`). + */ + get latestMessage(): LocalMessage | null { + return this.aggregateState.getLatestValue().lastMessage; } /** @@ -107,7 +174,7 @@ export class MessagePaginator extends MessageIntervalPaginator { * "latest message", so the exclusions are skipped and only the base rule applies. */ protected shouldAdvanceLatestMessage(message: LocalMessage): boolean { - if (!super.shouldAdvanceLatestMessage(message)) return false; + if (message.shadowed) return false; if (this.parentMessageId) return true; const isThreadOnlyReply = !!message.parent_id && !message.show_in_channel; if (isThreadOnlyReply) return false; @@ -117,6 +184,67 @@ export class MessagePaginator extends MessageIntervalPaginator { return !skipSystemMessage; } + /** + * Monotonically advance {@link lastMessageAt} to `message.created_at` when it is newer than the + * current value and {@link shouldAdvanceLatestMessage} permits it. Writes the dedicated + * {@link aggregateState} store (not `state`), so it is safe to call from inside a `state.next` + * updater (`ingestPage`). Returns `true` when the value moved. + * + * Called internally on ingest; also public for callers that advance the aggregate without a normal + * in-window ingest — e.g. offline pending-message replay, where the sent message has not yet been + * ingested via the `message.new` event. + */ + trackLatestMessage(message: LocalMessage): boolean { + if (!this.shouldAdvanceLatestMessage(message)) return false; + const incoming = getMessageCreatedAtTimestamp(message); + if (incoming === null) return false; + // Guard against the current display message's own timestamp — NOT `lastMessageAt` (which the + // server seed can inflate). Otherwise, a seed newer than the loaded window would reject the very + // message it was derived from, and `lastMessage` would never populate. + const current = this.aggregateState.getLatestValue().lastMessage; + const currentTs = current ? getMessageCreatedAtTimestamp(current) : null; + if (currentTs !== null && incoming <= currentTs) return false; + this.aggregateState.partialNext({ lastMessage: message }); + return true; + } + + /** + * Seed {@link lastMessageAt} from the server-provided `ChannelResponse.last_message_at`, the + * authoritative whole-channel aggregate. Monotonic: a no-op when the paginator already advanced + * past it (e.g. from ingested messages), so seed order does not matter. + */ + seedLastMessageAt(value: string | Date | null | undefined) { + if (!value) return; + const date = value instanceof Date ? value : new Date(value); + const timestamp = date.getTime(); + if (!Number.isFinite(timestamp)) return; + const current = this.aggregateState.getLatestValue().seededLastMessageAt; + if (current && timestamp <= current.getTime()) return; + this.aggregateState.partialNext({ seededLastMessageAt: date }); + } + + ingestItem(item: LocalMessage): boolean { + // Only items that survive the filter advance the aggregate (`ingestItem` also handles removals + // of items that no longer match). The advance writes the separate `aggregateState` store, so it + // is independent of `super`'s `state`/index mutations. + if (this.matchesFilter(item)) { + this.trackLatestMessage(item); + } + return super.ingestItem(item); + } + + ingestPage( + params: Parameters[0], + ): Interval | null { + const interval = super.ingestPage(params); + // Advance from each page item; the monotonic max over the page yields the newest. Comparison is + // by `created_at` against `aggregateState` (no index lookup), so order vs `super` is irrelevant. + if (params.page?.length) { + for (const item of params.page) this.trackLatestMessage(item); + } + return interval; + } + /** * (Re)seed the unread state snapshot from the current own read state. * @@ -310,5 +438,6 @@ export class MessagePaginator extends MessageIntervalPaginator { clearStateAndCache() { super.clearStateAndCache(); this.clearUnreadSnapshot(); + this.aggregateState.next({ lastMessage: null, seededLastMessageAt: null }); } } diff --git a/src/pagination/paginators/ReminderPaginator.ts b/src/pagination/paginators/ReminderPaginator.ts index 8c5022452..7a5480ee8 100644 --- a/src/pagination/paginators/ReminderPaginator.ts +++ b/src/pagination/paginators/ReminderPaginator.ts @@ -11,6 +11,17 @@ import type { ReminderSort, } from '../../types'; import type { StreamChat } from '../../client'; +import { ItemIndex } from '../ItemIndex'; +import { makeComparator } from '../sortCompiler'; +import { resolveDotPathValue } from '../utility.normalization'; + +// Reminders are keyed by the message they belong to; used for interval dedup and index addressing. +const getReminderId = (reminder: ReminderResponse) => reminder.message_id; + +// Fallback order for interval placement when no explicit sort is set. Order is not a pinned contract +// (ReminderManager stores reminders in a message_id-keyed Map), but interval storage needs a total +// order, so default to a deterministic one. +const DEFAULT_SORT: ReminderSort = { created_at: 1 }; export class ReminderPaginator extends BasePaginator< ReminderResponse, @@ -35,6 +46,7 @@ export class ReminderPaginator extends BasePaginator< set sort(sort: ReminderSort | undefined) { this._sort = sort; + this.sortComparator = this.buildSortComparator(); this.resetState(); } @@ -42,8 +54,28 @@ export class ReminderPaginator extends BasePaginator< client: StreamChat, options?: PaginatorOptions, ) { - super({ initialCursor: ZERO_PAGE_CURSOR, ...options }); + super({ + initialCursor: ZERO_PAGE_CURSOR, + itemIndex: new ItemIndex({ getId: getReminderId }), + ...options, + }); this.client = client; + this.sortComparator = this.buildSortComparator(); + } + + getItemId(item: ReminderResponse): string { + return getReminderId(item); + } + + // Interval storage needs a total order. Derive it from the requested sort (rebuilt when `sort` + // changes, which also resets the accumulated pages), with a message_id tiebreaker. + private buildSortComparator() { + return makeComparator({ + sort: this._sort ?? DEFAULT_SORT, + resolvePathValue: resolveDotPathValue, + tiebreaker: (l, r) => + l.message_id < r.message_id ? -1 : l.message_id > r.message_id ? 1 : 0, + }); } protected getNextQueryShape({ diff --git a/src/pagination/paginators/UserGroupPaginator.ts b/src/pagination/paginators/UserGroupPaginator.ts index 5ee5ba0d1..bc19ee1f7 100644 --- a/src/pagination/paginators/UserGroupPaginator.ts +++ b/src/pagination/paginators/UserGroupPaginator.ts @@ -7,6 +7,7 @@ import type { } from './BasePaginator'; import type { QueryUserGroupsOptions, UserGroupResponse } from '../../types'; import type { StreamChat } from '../../client'; +import { ItemIndex } from '../ItemIndex'; type UserGroupListCursor = { created_at_gt: string; @@ -46,8 +47,19 @@ export class UserGroupPaginator extends BasePaginator< client: StreamChat, options?: PaginatorOptions, ) { - super({ initialCursor: { ...ZERO_PAGE_CURSOR, headward: null }, ...options }); + super({ + initialCursor: { ...ZERO_PAGE_CURSOR, headward: null }, + itemIndex: new ItemIndex({ getId: (group) => group.id }), + ...options, + }); this.client = client; + // Interval storage needs a total order for its placement/merge math. The listing is ordered by + // the forward cursor (`created_at_gt`, `id_gt`), i.e. ascending `created_at` then `id` — mirror + // that here so the visible order matches the server's. + this.sortComparator = (a, b) => { + if (a.created_at !== b.created_at) return a.created_at < b.created_at ? -1 : 1; + return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; + }; } get initialState(): PaginatorState { diff --git a/src/thread.ts b/src/thread.ts index 36d38e294..a1461da34 100644 --- a/src/thread.ts +++ b/src/thread.ts @@ -241,6 +241,11 @@ export class Thread extends WithSubscriptions { }); } + // Seed the reply paginator's lastMessageAt floor from the thread's server-provided + // `last_message_at` (analogous to the channel seed in Channel._initializeState), so a thread whose + // newest reply is not among `latest_replies` still reports the correct latest-activity timestamp. + this.messagePaginator.seedLastMessageAt(threadData?.last_message_at); + this.messageComposer = new MessageComposer({ client, composition: threadData?.draft ?? draft, @@ -396,6 +401,9 @@ export class Thread extends WithSubscriptions { thread.messagePaginator.state.getLatestValue().items ?? [], ); pendingReplies.forEach((reply) => this.messagePaginator.ingestItem(reply)); + // Carry the re-queried thread's last-activity floor so lastMessageAt stays fresh even when the + // merged page does not include the newest reply. Monotonic, so an older value is a no-op. + this.messagePaginator.seedLastMessageAt(thread.messagePaginator.lastMessageAt); }; public registerSubscriptions = () => { diff --git a/test/unit/channel.test.js b/test/unit/channel.test.js index f173d337a..06f9afa27 100644 --- a/test/unit/channel.test.js +++ b/test/unit/channel.test.js @@ -2662,7 +2662,7 @@ describe('Channel lastMessage', async () => { // ingestion advances the tracked latest, skipping the newest (system) message per config. seedLatestWindow(channel, latestMessages); - expect(channel.state.last_message_at.getTime()).toBe( + expect(channel.messagePaginator.lastMessageAt.getTime()).toBe( new Date(latestMessages[1].created_at).getTime(), ); }); @@ -2681,41 +2681,49 @@ describe('Channel last_message_at', () => { const track = (msg) => channel.messagePaginator.trackLatestMessage(formatMessage(msg)); it('advances monotonically as messages are tracked', () => { - expect(channel.state.last_message_at).to.be.null; + expect(channel.messagePaginator.lastMessageAt).to.be.null; track(generateMsg({ id: '0', date: '2020-01-01T00:00:00.000Z' })); - expect(channel.state.last_message_at.getTime()).to.be.equal( + expect(channel.messagePaginator.lastMessageAt.getTime()).to.be.equal( new Date('2020-01-01T00:00:00.000Z').getTime(), ); track(generateMsg({ id: '1', date: '2019-01-01T00:00:00.000Z' })); - expect(channel.state.last_message_at.getTime()).to.be.equal( + expect(channel.messagePaginator.lastMessageAt.getTime()).to.be.equal( new Date('2020-01-01T00:00:00.000Z').getTime(), ); track(generateMsg({ id: '2', date: '2020-01-01T00:00:00.001Z' })); - expect(channel.state.last_message_at.getTime()).to.be.equal( + expect(channel.messagePaginator.lastMessageAt.getTime()).to.be.equal( new Date('2020-01-01T00:00:00.001Z').getTime(), ); }); - it('derives from the message paginator latestMessage', () => { - track(generateMsg({ id: '0', date: '2020-01-01T00:00:00.000Z' })); - expect(channel.messagePaginator.latestMessage?.id).to.be.equal('0'); - expect(channel.state.last_message_at.getTime()).to.be.equal( - channel.messagePaginator.latestMessage.created_at.getTime(), - ); - }); - it('is not advanced by a thread-only reply', () => { track( generateMsg({ id: 'reply', date: '2020-01-01T00:00:00.000Z', parent_id: 'parent' }), ); - expect(channel.state.last_message_at).to.be.null; + expect(channel.messagePaginator.lastMessageAt).to.be.null; + }); + + it('is null when nothing has been tracked or seeded', () => { + expect(channel.messagePaginator.lastMessageAt).to.be.null; }); - it('is null when no message is tracked (derived from the paginator, read-only)', () => { - // The writable setter was removed: last_message_at is a projection of the message paginator. - expect(channel.state.last_message_at).to.be.null; + it('is seeded from the server-provided last_message_at', () => { + // A channel surfaced by the channel-list query: lastMessageAt is seeded from the server + // aggregate so it sorts correctly even before its message paginator loads a page. + channel.messagePaginator.seedLastMessageAt('2023-05-03T11:12:53.993Z'); + expect(channel.messagePaginator.lastMessageAt.getTime()).to.be.equal( + new Date('2023-05-03T11:12:53.993Z').getTime(), + ); + }); + + it('advances past the seeded value when a newer message is tracked (monotonic max)', () => { + channel.messagePaginator.seedLastMessageAt('2020-01-01T00:00:00.000Z'); + track(generateMsg({ id: '0', date: '2021-06-01T00:00:00.000Z' })); + expect(channel.messagePaginator.lastMessageAt.getTime()).to.be.equal( + new Date('2021-06-01T00:00:00.000Z').getTime(), + ); }); }); diff --git a/test/unit/pagination/paginators/BasePaginator.test.ts b/test/unit/pagination/paginators/BasePaginator.test.ts index edb686bc0..585c4c17b 100644 --- a/test/unit/pagination/paginators/BasePaginator.test.ts +++ b/test/unit/pagination/paginators/BasePaginator.test.ts @@ -305,13 +305,15 @@ describe('BasePaginator', () => { await sleep(0); expect(paginator.isLoading).toBe(true); expect(paginator.hasMoreTail).toBe(true); - expect(paginator.hasMoreHead).toBe(true); + // Offset pagination establishes its window from the start offset (0 here) as soon as the + // first-page load begins, so the head is known to be loaded before the query resolves. + expect(paginator.hasMoreHead).toBe(false); paginator.queryResolve({ items: [{ id: 'id1' }] }); await nextPromise; expect(paginator.isLoading).toBe(false); expect(paginator.hasMoreTail).toBe(true); - expect(paginator.hasMoreHead).toBe(true); + expect(paginator.hasMoreHead).toBe(false); expect(paginator.items).toEqual([{ id: 'id1' }]); expect(paginator.cursor).toBeUndefined(); expect(paginator.offset).toBe(1); @@ -327,7 +329,7 @@ describe('BasePaginator', () => { paginator.queryResolve({ items: [{ id: 'id2' }] }); await nextPromise; expect(paginator.hasMoreTail).toBe(true); - expect(paginator.hasMoreHead).toBe(true); + expect(paginator.hasMoreHead).toBe(false); expect(paginator.items).toEqual([{ id: 'id1' }, { id: 'id2' }]); expect(paginator.cursor).toBeUndefined(); expect(paginator.offset).toBe(2); @@ -336,7 +338,7 @@ describe('BasePaginator', () => { paginator.queryResolve({ items: [] }); await nextPromise; expect(paginator.hasMoreTail).toBe(false); - expect(paginator.hasMoreHead).toBe(true); + expect(paginator.hasMoreHead).toBe(false); expect(paginator.items).toEqual([{ id: 'id1' }, { id: 'id2' }]); expect(paginator.cursor).toBeUndefined(); expect(paginator.offset).toBe(2); @@ -346,6 +348,61 @@ describe('BasePaginator', () => { expect(paginator.mockClientQuery).toHaveBeenCalledTimes(3); }); + it('keeps hasMoreHead unchanged on a keepPreviousItems first-page refresh (offset)', async () => { + // Regression: hasMoreHead is derived from the start offset only when the first page resets + // the window (isFirstPage && !keepPreviousItems). A keepPreviousItems refresh is isFirstPage + // but does NOT reset the offset, so it must not re-derive hasMoreHead from the grown offset. + const paginator = new Paginator({ pageSize: 1 }); + + // First page from offset 0 -> head is loaded. + let nextPromise = paginator.toTail(); + await sleep(0); + paginator.queryResolve({ items: [{ id: 'id1' }] }); + await nextPromise; + expect(paginator.hasMoreHead).toBe(false); + expect(paginator.offset).toBe(1); + + // Grow the tail so the offset is well past 0. + nextPromise = paginator.toTail(); + paginator.queryResolve({ items: [{ id: 'id2' }] }); + await nextPromise; + expect(paginator.offset).toBe(2); + expect(paginator.hasMoreHead).toBe(false); + + // A non-destructive first-page refresh (isFirstPage via reset, keepPreviousItems) must NOT + // flip hasMoreHead to true off the grown offset (2 > 0) — the window still starts at 0. + const refreshPromise = paginator.executeQuery({ + keepPreviousItems: true, + reset: 'yes', + }); + paginator.queryResolve({ items: [{ id: 'id1' }] }); + await refreshPromise; + expect(paginator.hasMoreHead).toBe(false); + }); + + it('anchors hasMoreHead from a new start offset when the window is re-established via reset (offset)', async () => { + // To start a window mid-list, set the start offset and reset. isFirstPage is true, + // getStateBeforeFirstQuery runs, and hasMoreHead is anchored from the (new) start offset. + const paginator = new Paginator({ pageSize: 10 }); + + // First window at the head (offset 0) -> head loaded. + let queryPromise = paginator.toTail(); + await sleep(0); + paginator.queryResolve({ items: [{ id: 'id1' }] }); + await queryPromise; + expect(paginator.hasMoreHead).toBe(false); + + // Move the start offset and re-establish the window from it (reload -> reset: 'yes'). + paginator.initialOffset = 30; + queryPromise = paginator.reload(); + await sleep(0); + paginator.queryResolve({ items: [{ id: 'id2' }] }); + await queryPromise; + + // Anchored correctly: a window starting at offset 30 reports items before it. + expect(paginator.hasMoreHead).toBe(true); + }); + it('paginates to next pages debounced (cursor)', async () => { vi.useFakeTimers(); const paginator = new Paginator({ @@ -400,7 +457,8 @@ describe('BasePaginator', () => { await toNextTick(); expect(paginator.isLoading).toBe(true); expect(paginator.hasMoreTail).toBe(true); - expect(paginator.hasMoreHead).toBe(true); + // Head is known to be loaded once the offset-0 first-page load begins (see non-debounced case). + expect(paginator.hasMoreHead).toBe(false); paginator.queryResolve({ items: [{ id: 'id1' }], @@ -409,7 +467,7 @@ describe('BasePaginator', () => { await toNextTick(); expect(paginator.isLoading).toBe(false); expect(paginator.hasMoreTail).toBe(true); - expect(paginator.hasMoreHead).toBe(true); + expect(paginator.hasMoreHead).toBe(false); expect(paginator.items).toEqual([{ id: 'id1' }]); expect(paginator.cursor).toBeUndefined(); expect(paginator.offset).toBe(1); @@ -635,7 +693,7 @@ describe('BasePaginator', () => { await nextPromise; expect(paginator.isLoading).toBe(false); expect(paginator.hasMoreTail).toBe(true); - expect(paginator.hasMoreHead).toBe(true); + expect(paginator.hasMoreHead).toBe(false); expect(paginator.items).toEqual([{ id: 'id1' }]); expect(paginator.cursor).toBeUndefined(); expect(paginator.offset).toBe(1); @@ -664,7 +722,7 @@ describe('BasePaginator', () => { await nextPromise; expect(paginator.isLoading).toBe(false); expect(paginator.hasMoreTail).toBe(true); - expect(paginator.hasMoreHead).toBe(true); + expect(paginator.hasMoreHead).toBe(false); expect(paginator.items).toEqual([{ id: 'id1' }]); expect(paginator.cursor).toBeUndefined(); expect(paginator.offset).toBe(1); @@ -689,7 +747,7 @@ describe('BasePaginator', () => { await nextPromise; expect(paginator.isLoading).toBe(false); expect(paginator.hasMoreTail).toBe(true); - expect(paginator.hasMoreHead).toBe(true); + expect(paginator.hasMoreHead).toBe(false); expect(paginator.items).toEqual([{ id: 'id1' }]); expect(paginator.cursor).toBeUndefined(); expect(paginator.offset).toBe(1); @@ -3116,55 +3174,15 @@ describe('BasePaginator', () => { }); }); - describe('headItems (reactive latest window)', () => { - it('is undefined before the first load and mirrors items in flat mode', () => { + describe('latestItems (newest loaded window)', () => { + it('is empty before the first load and mirrors items in flat mode', () => { const paginator = new Paginator(); - expect(paginator.headItems).toBeUndefined(); + expect(paginator.latestItems).toEqual([]); const loaded = [{ id: 'a' }]; paginator.setItems({ valueOrFactory: loaded }); - expect(paginator.headItems).toStrictEqual(loaded); - // flat-mode head window is the items array itself (same reference) - expect(paginator.state.getLatestValue().headItems).toBe(loaded); - }); - - it('emits reactively when the head window changes', () => { - const paginator = new Paginator(); - const emissions: Array<{ id: string }[] | undefined> = []; - const unsubscribe = paginator.state.subscribeWithSelector( - (state) => ({ headItems: state.headItems }), - ({ headItems }) => emissions.push(headItems), - ); - - const items1 = [{ id: 'a' }]; - const items2 = [{ id: 'b' }]; - paginator.setItems({ valueOrFactory: items1 }); - paginator.setItems({ valueOrFactory: items2 }); - - unsubscribe(); - // initial (undefined) + one emission per head-window change - expect(emissions).toEqual([undefined, items1, items2]); - }); - - it('does not re-emit headItems on unrelated state changes (reference-stable)', () => { - const paginator = new Paginator(); - paginator.setItems({ valueOrFactory: [{ id: 'a' }] }); - - let calls = 0; - const unsubscribe = paginator.state.subscribeWithSelector( - (state) => ({ headItems: state.headItems }), - () => { - calls += 1; - }, - ); - expect(calls).toBe(1); // initial - - paginator.state.partialNext({ isLoading: true }); - paginator.state.partialNext({ isLoading: false }); - - expect(calls).toBe(1); // head window did not change → no re-emit - unsubscribe(); + expect(paginator.latestItems).toStrictEqual(loaded); }); it('materializes the head window in interval-storage mode', () => { @@ -3181,8 +3199,8 @@ describe('BasePaginator', () => { setActive: true, }); - expect(paginator.headItems).toBeDefined(); - expect(paginator.headItems?.map((item) => item.id)).toEqual( + expect(paginator.latestItems.length).toBeGreaterThan(0); + expect(paginator.latestItems.map((item) => item.id)).toEqual( paginator.items?.map((item) => item.id), ); itemIndex.clear(); @@ -3217,8 +3235,6 @@ describe('BasePaginator', () => { hasMoreHead: true, isLoading: false, items, - // flat-mode paginator: the head window is the full list - headItems: items, lastQueryError: undefined, offset: 1, }, @@ -3422,13 +3438,15 @@ describe('BasePaginator', () => { await sleep(0); expect(paginator.isLoading).toBe(true); expect(paginator.hasMoreTail).toBe(true); - expect(paginator.hasMoreHead).toBe(true); + // reload() restarts offset pagination from the beginning (offset 0), so the head is loaded + // as soon as the reload query begins — before it resolves. + expect(paginator.hasMoreHead).toBe(false); paginator.queryResolve({ items: [{ id: 'id1' }] }); await reloadPromise; expect(paginator.isLoading).toBe(false); expect(paginator.hasMoreTail).toBe(false); - expect(paginator.hasMoreHead).toBe(true); + expect(paginator.hasMoreHead).toBe(false); expect(paginator.items).toEqual([{ id: 'id1' }]); expect(paginator.cursor).toBeUndefined(); expect(paginator.offset).toBe(1); @@ -3444,13 +3462,14 @@ describe('BasePaginator', () => { await sleep(0); expect(paginator.isLoading).toBe(true); expect(paginator.hasMoreTail).toBe(true); - expect(paginator.hasMoreHead).toBe(true); + // Offset-0 reload again: head loaded from the start of the reload query. + expect(paginator.hasMoreHead).toBe(false); paginator.queryResolve({ items: [{ id: 'id2' }], tailward: 'next2' }); await reloadPromise; expect(paginator.isLoading).toBe(false); expect(paginator.hasMoreTail).toBe(false); - expect(paginator.hasMoreHead).toBe(true); + expect(paginator.hasMoreHead).toBe(false); expect(paginator.items).toEqual([{ id: 'id2' }]); expect(paginator.cursor).toBeUndefined(); expect(paginator.offset).toBe(1); diff --git a/test/unit/pagination/paginators/ChannelPaginator.test.ts b/test/unit/pagination/paginators/ChannelPaginator.test.ts index 210e896d4..575bd08ee 100644 --- a/test/unit/pagination/paginators/ChannelPaginator.test.ts +++ b/test/unit/pagination/paginators/ChannelPaginator.test.ts @@ -53,7 +53,7 @@ describe('ChannelPaginator', () => { expect(paginator.pageSize).toBe(DEFAULT_PAGINATION_OPTIONS.pageSize); expect(paginator.state.getLatestValue()).toEqual({ hasMoreTail: true, - hasMoreHead: true, + hasMoreHead: true, // initial state (pre-query); becomes false after the first offset-0 query isLoading: false, items: undefined, lastQueryError: undefined, @@ -602,53 +602,51 @@ describe('ChannelPaginator', () => { }); describe('setters', () => { - const stateAfterQuery = { - items: [channel1, channel2], - // flat-mode paginator: the head window mirrors the full list - headItems: [channel1, channel2], - hasMoreTail: false, - hasMoreHead: false, - offset: 10, - isLoading: false, - lastQueryError: undefined, - cursor: undefined, + // Seed via the real ingestion path (distinct cids — interval storage dedupes by cid) and capture + // the resulting state. These setters must not re-emit / reset it, so the state reference should + // be identical afterwards. + const seed = (paginator: ChannelPaginator) => { + const a = new Channel(client, 'type', 'setter-a', {}); + const b = new Channel(client, 'type', 'setter-b', {}); + paginator.setItems({ + valueOrFactory: [a, b], + isFirstPage: true, + isLastPage: true, + }); + return paginator.state.getLatestValue(); }; it('filters reset does not reset the paginator state', () => { const paginator = new ChannelPaginator({ client }); - paginator.state.partialNext(stateAfterQuery); - expect(paginator.state.getLatestValue()).toStrictEqual(stateAfterQuery); + const before = seed(paginator); paginator.staticFilters = {}; - expect(paginator.state.getLatestValue()).toStrictEqual(stateAfterQuery); + expect(paginator.state.getLatestValue()).toBe(before); expect(paginator.staticFilters).toStrictEqual({}); }); it('sort reset does not reset the paginator state updates the comparator', () => { const paginator = new ChannelPaginator({ client }); - paginator.state.partialNext(stateAfterQuery); - expect(paginator.state.getLatestValue()).toStrictEqual(stateAfterQuery); + const before = seed(paginator); const originalComparator = paginator.sortComparator; paginator.sort = {}; - expect(paginator.state.getLatestValue()).toStrictEqual(stateAfterQuery); + expect(paginator.state.getLatestValue()).toBe(before); expect(paginator.sort).toStrictEqual({}); expect(paginator.sortComparator).not.toEqual(originalComparator); }); it('options reset does not reset the paginator state', () => { const paginator = new ChannelPaginator({ client }); - paginator.state.partialNext(stateAfterQuery); - expect(paginator.state.getLatestValue()).toStrictEqual(stateAfterQuery); + const before = seed(paginator); paginator.options = {}; - expect(paginator.state.getLatestValue()).toStrictEqual(stateAfterQuery); + expect(paginator.state.getLatestValue()).toBe(before); expect(paginator.options).toStrictEqual({}); }); it('channelStateOptions reset does not reset the paginator state', () => { const paginator = new ChannelPaginator({ client }); - paginator.state.partialNext(stateAfterQuery); - expect(paginator.state.getLatestValue()).toStrictEqual(stateAfterQuery); + const before = seed(paginator); paginator.channelStateOptions = {}; - expect(paginator.state.getLatestValue()).toStrictEqual(stateAfterQuery); + expect(paginator.state.getLatestValue()).toBe(before); expect(paginator.channelStateOptions).toStrictEqual({}); }); }); @@ -727,4 +725,101 @@ describe('ChannelPaginator', () => { ); }); }); + + describe('interval storage', () => { + it('is index-addressable by cid, populates latestItems, and dedupes across pages', async () => { + const a = new Channel(client, 'type', 'iv-a', {}); + const b = new Channel(client, 'type', 'iv-b', {}); + let page: Channel[] = [a, b]; + const paginator = new ChannelPaginator({ + client, + paginatorOptions: { + doRequest: () => Promise.resolve({ items: page }), + pageSize: 2, + }, + }); + + await paginator.executeQuery({}); + + // resolvable by cid + mirrored into the head window (interval storage) + expect(paginator.getItem('type:iv-a')).toBe(a); + expect(paginator.getItem('type:iv-b')).toBe(b); + expect(paginator.latestItems.map((c) => c.cid).sort()).toEqual([ + 'type:iv-a', + 'type:iv-b', + ]); + + // next offset page returns an already-loaded channel — dedup keeps a single entry + page = [a]; + await paginator.toTail(); + const cids = (paginator.items ?? []).map((c) => c.cid); + expect(cids.filter((cid) => cid === 'type:iv-a')).toHaveLength(1); + }); + + it('keeps the head (newest) at index 0 and the tail (oldest) at the end', async () => { + // Contrary to the message list, the channel list is head-first: the newest (head) item sits at + // the top (index 0) and the oldest (tail) at the bottom. + const newest = new Channel(client, 'type', 'newest', {}); + const middle = new Channel(client, 'type', 'middle', {}); + const oldest = new Channel(client, 'type', 'oldest', {}); + setLastMessageAt(newest, new Date('2020-03-01T00:00:00.000Z')); + setLastMessageAt(middle, new Date('2020-02-01T00:00:00.000Z')); + setLastMessageAt(oldest, new Date('2020-01-01T00:00:00.000Z')); + + const paginator = new ChannelPaginator({ + client, + paginatorOptions: { + // server returns them out of order; interval storage sorts by the default (desc) comparator + doRequest: () => Promise.resolve({ items: [middle, oldest, newest] }), + pageSize: 10, + }, + }); + + await paginator.executeQuery({}); + + expect(paginator.items?.map((c) => c.cid)).toEqual([ + 'type:newest', + 'type:middle', + 'type:oldest', + ]); + // head edge = index 0 = newest; head window starts with it too + expect(paginator.latestItem?.cid).toBe('type:newest'); + expect(paginator.latestItems[0]?.cid).toBe('type:newest'); + }); + + it('promotes a non-headmost channel to the top on re-ingest without dropping it', async () => { + // Reproduces the reorder-on-new-message bug: a channel below the head gets a newer + // last_message_at and is re-ingested (as the orchestrator does on message.new). It must move to + // the top and stay visible — not escape into the logical-head interval and disappear. + const a = new Channel(client, 'type', 'a', {}); + const b = new Channel(client, 'type', 'b', {}); + const c = new Channel(client, 'type', 'c', {}); + setLastMessageAt(a, new Date('2020-03-01T00:00:00.000Z')); + setLastMessageAt(b, new Date('2020-02-01T00:00:00.000Z')); + setLastMessageAt(c, new Date('2020-01-01T00:00:00.000Z')); // oldest / non-headmost + + const paginator = new ChannelPaginator({ + client, + paginatorOptions: { + doRequest: () => Promise.resolve({ items: [a, b, c] }), + pageSize: 10, + }, + }); + await paginator.executeQuery({}); + expect(paginator.items?.map((ch) => ch.cid)).toEqual([ + 'type:a', + 'type:b', + 'type:c', + ]); + + // c receives a new message → newest; re-ingest to reposition (mirrors updateLists) + setLastMessageAt(c, new Date('2020-04-01T00:00:00.000Z')); + paginator.ingestItem(c); + + const cids = paginator.items?.map((ch) => ch.cid); + expect(cids).toContain('type:c'); // not dropped + expect(cids?.[0]).toBe('type:c'); // moved to the head (top) + expect(cids).toHaveLength(3); // no duplicates, nothing lost + }); + }); }); diff --git a/test/unit/pagination/paginators/MessagePaginator.test.ts b/test/unit/pagination/paginators/MessagePaginator.test.ts index ea5149023..e611692ef 100644 --- a/test/unit/pagination/paginators/MessagePaginator.test.ts +++ b/test/unit/pagination/paginators/MessagePaginator.test.ts @@ -48,9 +48,12 @@ describe('MessagePaginator', () => { isLoading: false, items: undefined, lastQueryError: undefined, - latestMessageId: null, offset: 0, }); + expect(paginator.aggregateState.getLatestValue()).toEqual({ + lastMessage: null, + seededLastMessageAt: null, + }); // @ts-expect-error accessing protected property expect(paginator._filterFieldToDataResolvers).toHaveLength(1); @@ -1906,7 +1909,7 @@ describe('MessagePaginator', () => { }); }); - describe('trackLatestMessage() / latestMessage', () => { + describe('trackLatestMessage() / lastMessageAt', () => { let skipSystemMessages: boolean; let trackingChannel: Channel; @@ -1924,94 +1927,136 @@ describe('MessagePaginator', () => { }); }; + const at = (iso: string) => new Date(iso).getTime(); + beforeEach(() => { skipSystemMessages = false; }); - it('is undefined until a message is tracked', () => { + it('is null until a message is tracked', () => { const paginator = buildPaginator(); - expect(paginator.state.getLatestValue().latestMessageId).toBeNull(); - expect(paginator.latestMessage).toBeUndefined(); + expect(paginator.aggregateState.getLatestValue()).toEqual({ + lastMessage: null, + seededLastMessageAt: null, + }); + expect(paginator.lastMessageAt).toBeNull(); + expect(paginator.latestMessage).toBeNull(); }); - it('tracks a message and resolves it from the index without ingesting a window', () => { + it('advances lastMessageAt and lastMessage without ingesting a window', () => { const paginator = buildPaginator(); - const message = createMessage({ id: 'a', created_at: '2020-01-01T00:00:00.000Z' }); - paginator.trackLatestMessage(message); + paginator.trackLatestMessage( + createMessage({ id: 'a', created_at: '2020-01-01T00:00:00.000Z' }), + ); - // `trackLatestMessage` updates the source-of-truth field (read by `latestMessage`) without - // emitting; `state.latestMessageId` is published by the mirror preprocessor on the next - // emission (verified in the reply-list auto-track cases below). + expect(paginator.lastMessageAt?.getTime()).toBe(at('2020-01-01T00:00:00.000Z')); + // The display message is tracked on aggregateState (reactive off-window), not the visible list. expect(paginator.latestMessage?.id).toBe('a'); - // tracked without ingesting into an interval - the visible window stays empty. expect(paginator.items).toBeUndefined(); }); - it('does not emit on its own, so the paired ingest is the single state update', () => { + it('seed advances only the timestamp, leaving lastMessage null', () => { + const paginator = buildPaginator(); + paginator.seedLastMessageAt('2023-05-03T11:12:53.993Z'); + expect(paginator.lastMessageAt?.getTime()).toBe(at('2023-05-03T11:12:53.993Z')); + // The server seed has a timestamp but not the message itself. + expect(paginator.latestMessage).toBeNull(); + }); + + it('lastMessageAt is the max of the loaded message and the seed; a seed never blocks the display message', () => { + const paginator = buildPaginator(); + // Server says the newest message is far in the future (not yet loaded). + paginator.seedLastMessageAt('2030-01-01T00:00:00.000Z'); + expect(paginator.lastMessageAt?.getTime()).toBe(at('2030-01-01T00:00:00.000Z')); + expect(paginator.latestMessage).toBeNull(); + + // A real (older-than-seed) message must still become the display message — the guard is against + // the display message's own timestamp, not the seed-inflated lastMessageAt. + paginator.trackLatestMessage( + createMessage({ id: 'a', created_at: '2020-01-01T00:00:00.000Z' }), + ); + expect(paginator.latestMessage?.id).toBe('a'); + // Sort key stays the max (the seed), so it can never drift below the display message. + expect(paginator.lastMessageAt?.getTime()).toBe(at('2030-01-01T00:00:00.000Z')); + + // Once a message newer than the seed arrives, lastMessageAt follows it. + paginator.trackLatestMessage( + createMessage({ id: 'b', created_at: '2031-01-01T00:00:00.000Z' }), + ); + expect(paginator.latestMessage?.id).toBe('b'); + expect(paginator.lastMessageAt?.getTime()).toBe(at('2031-01-01T00:00:00.000Z')); + }); + + it('does not emit on the pagination state (writes the separate aggregateState store)', () => { const paginator = buildPaginator(); - let emissions = 0; + let stateEmissions = 0; const unsubscribe = paginator.state.subscribe(() => { - emissions += 1; + stateEmissions += 1; }); - emissions = 0; // ignore the synchronous initial subscribe call + stateEmissions = 0; // ignore the synchronous initial subscribe call paginator.trackLatestMessage( createMessage({ id: 'a', created_at: '2020-01-01T00:00:00.000Z' }), ); unsubscribe(); - expect(emissions).toBe(0); - // the field is still updated (read by latestMessage / last_message_at) - expect(paginator.latestMessage?.id).toBe('a'); + expect(stateEmissions).toBe(0); + expect(paginator.lastMessageAt?.getTime()).toBe(at('2020-01-01T00:00:00.000Z')); }); it('advances monotonically by created_at', () => { const paginator = buildPaginator(); - const first = createMessage({ id: 'a', created_at: '2020-01-01T00:00:00.000Z' }); - const older = createMessage({ id: 'b', created_at: '2019-01-01T00:00:00.000Z' }); - const newer = createMessage({ id: 'c', created_at: '2021-01-01T00:00:00.000Z' }); - paginator.trackLatestMessage(first); - paginator.trackLatestMessage(older); - expect(paginator.latestMessage?.id).toBe('a'); + paginator.trackLatestMessage( + createMessage({ id: 'a', created_at: '2020-01-01T00:00:00.000Z' }), + ); + paginator.trackLatestMessage( + createMessage({ id: 'b', created_at: '2019-01-01T00:00:00.000Z' }), + ); + expect(paginator.lastMessageAt?.getTime()).toBe(at('2020-01-01T00:00:00.000Z')); - paginator.trackLatestMessage(newer); - expect(paginator.latestMessage?.id).toBe('c'); + paginator.trackLatestMessage( + createMessage({ id: 'c', created_at: '2021-01-01T00:00:00.000Z' }), + ); + expect(paginator.lastMessageAt?.getTime()).toBe(at('2021-01-01T00:00:00.000Z')); }); - it('never advances to a shadowed message', () => { + it('never advances for a shadowed message', () => { const paginator = buildPaginator(); - const shadowed = createMessage({ - id: 'a', - created_at: '2020-01-01T00:00:00.000Z', - shadowed: true, - }); - paginator.trackLatestMessage(shadowed); + paginator.trackLatestMessage( + createMessage({ + id: 'a', + created_at: '2020-01-01T00:00:00.000Z', + shadowed: true, + }), + ); - expect(paginator.latestMessage).toBeUndefined(); + expect(paginator.lastMessageAt).toBeNull(); }); - it('never advances to a thread-only reply, but does for a reply shown in the channel', () => { + it('never advances for a thread-only reply, but does for a reply shown in the channel', () => { const paginator = buildPaginator(); - const threadOnlyReply = createMessage({ - id: 'reply', - parent_id: 'parent', - created_at: '2020-01-01T00:00:00.000Z', - }); - const shownReply = createMessage({ - id: 'reply-shown', - parent_id: 'parent', - show_in_channel: true, - created_at: '2021-01-01T00:00:00.000Z', - }); - paginator.trackLatestMessage(threadOnlyReply); - expect(paginator.latestMessage).toBeUndefined(); + paginator.trackLatestMessage( + createMessage({ + id: 'reply', + parent_id: 'parent', + created_at: '2020-01-01T00:00:00.000Z', + }), + ); + expect(paginator.lastMessageAt).toBeNull(); - paginator.trackLatestMessage(shownReply); - expect(paginator.latestMessage?.id).toBe('reply-shown'); + paginator.trackLatestMessage( + createMessage({ + id: 'reply-shown', + parent_id: 'parent', + show_in_channel: true, + created_at: '2021-01-01T00:00:00.000Z', + }), + ); + expect(paginator.lastMessageAt?.getTime()).toBe(at('2021-01-01T00:00:00.000Z')); }); it('skips system messages only when skip_last_msg_update_for_system_msgs is set', () => { @@ -2023,12 +2068,12 @@ describe('MessagePaginator', () => { created_at: '2020-01-01T00:00:00.000Z', }); skipping.trackLatestMessage(systemMessage); - expect(skipping.latestMessage).toBeUndefined(); + expect(skipping.lastMessageAt).toBeNull(); skipSystemMessages = false; const tracking = buildPaginator(); tracking.trackLatestMessage(systemMessage); - expect(tracking.latestMessage?.id).toBe('sys'); + expect(tracking.lastMessageAt?.getTime()).toBe(at('2020-01-01T00:00:00.000Z')); }); it('auto-tracks on ingestion for the main channel list too', () => { @@ -2040,9 +2085,30 @@ describe('MessagePaginator', () => { created_at: '2020-01-01T00:00:00.000Z', }), ); - // The main list no longer relies on an explicit channel-level call: ingestion tracks latest, - // and last_message_at is derived from it. - expect(paginator.latestMessage?.id).toBe('a'); + // The main list no longer relies on an explicit channel-level call: ingestion advances the + // lastMessageAt aggregate directly. + expect(paginator.lastMessageAt?.getTime()).toBe(at('2020-01-01T00:00:00.000Z')); + }); + + it('seeds lastMessageAt from the server value (monotonic)', () => { + const paginator = buildPaginator(); + + paginator.seedLastMessageAt('2020-06-01T00:00:00.000Z'); + expect(paginator.lastMessageAt?.getTime()).toBe(at('2020-06-01T00:00:00.000Z')); + + // an older server value does not move it back + paginator.seedLastMessageAt('2020-01-01T00:00:00.000Z'); + expect(paginator.lastMessageAt?.getTime()).toBe(at('2020-06-01T00:00:00.000Z')); + + // a newer ingested message advances past the seed + paginator.ingestItem( + createMessage({ + id: 'a', + cid: 'channel-id', + created_at: '2021-01-01T00:00:00.000Z', + }), + ); + expect(paginator.lastMessageAt?.getTime()).toBe(at('2021-01-01T00:00:00.000Z')); }); describe('reply list (parentMessageId) auto-tracks on ingestion', () => { @@ -2058,14 +2124,14 @@ describe('MessagePaginator', () => { const paginator = buildPaginator('parent'); paginator.ingestItem(reply('r2', '2020-01-01T00:00:02.000Z')); - expect(paginator.latestMessage?.id).toBe('r2'); + expect(paginator.lastMessageAt?.getTime()).toBe(at('2020-01-01T00:00:02.000Z')); - // an older reply arriving later must not move the pointer back + // an older reply arriving later must not move the value back paginator.ingestItem(reply('r1', '2020-01-01T00:00:01.000Z')); - expect(paginator.latestMessage?.id).toBe('r2'); + expect(paginator.lastMessageAt?.getTime()).toBe(at('2020-01-01T00:00:02.000Z')); paginator.ingestItem(reply('r3', '2020-01-01T00:00:03.000Z')); - expect(paginator.latestMessage?.id).toBe('r3'); + expect(paginator.lastMessageAt?.getTime()).toBe(at('2020-01-01T00:00:03.000Z')); }); it('advances to the newest reply when a page is seeded via setItems', () => { @@ -2080,24 +2146,20 @@ describe('MessagePaginator', () => { isFirstPage: true, }); - expect(paginator.latestMessage?.id).toBe('r3'); - // The mirror preprocessor publishes the tracked id into state on the setItems emission - - // no separate emission from the tracking itself. - expect(paginator.state.getLatestValue().latestMessageId).toBe('r3'); + expect(paginator.lastMessageAt?.getTime()).toBe(at('2020-01-01T00:00:03.000Z')); }); }); - it('clears the tracked latest message on clearStateAndCache()', () => { + it('resets lastMessageAt on clearStateAndCache()', () => { const paginator = buildPaginator(); paginator.trackLatestMessage( createMessage({ id: 'a', created_at: '2020-01-01T00:00:00.000Z' }), ); - expect(paginator.latestMessage?.id).toBe('a'); + expect(paginator.lastMessageAt?.getTime()).toBe(at('2020-01-01T00:00:00.000Z')); paginator.clearStateAndCache(); - expect(paginator.state.getLatestValue().latestMessageId).toBeNull(); - expect(paginator.latestMessage).toBeUndefined(); + expect(paginator.lastMessageAt).toBeNull(); }); }); }); diff --git a/test/unit/pagination/paginators/ReminderPaginator.test.ts b/test/unit/pagination/paginators/ReminderPaginator.test.ts new file mode 100644 index 000000000..386d166cb --- /dev/null +++ b/test/unit/pagination/paginators/ReminderPaginator.test.ts @@ -0,0 +1,97 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ReminderResponse, StreamChat } from '../../../../src'; +import { ReminderPaginator } from '../../../../src/pagination/paginators/ReminderPaginator'; +import { getClientWithUser } from '../../test-utils/getClient'; + +const makeReminder = (messageId: string, createdAt: string): ReminderResponse => + ({ + channel_cid: 'messaging:x', + created_at: createdAt, + updated_at: createdAt, + user_id: 'user', + message_id: messageId, + }) as unknown as ReminderResponse; + +const response = ( + reminders: ReminderResponse[], + cursors: { next?: string; prev?: string } = {}, +) => ({ duration: '', reminders, ...cursors }); + +describe('ReminderPaginator', () => { + let client: StreamChat; + + beforeEach(() => { + client = getClientWithUser({ id: 'user' }); + }); + + it('stores results in interval storage keyed by message_id', async () => { + const paginator = new ReminderPaginator(client, { pageSize: 2 }); + vi.spyOn(client, 'queryReminders').mockResolvedValue( + response( + [ + makeReminder('m1', '2020-01-01T00:00:00.000Z'), + makeReminder('m2', '2020-01-02T00:00:00.000Z'), + ], + { next: 'next-cursor' }, + ), + ); + + await paginator.executeQuery({}); + + expect(paginator.items?.map((r) => r.message_id)).toEqual(['m1', 'm2']); + // interval storage: addressable by message_id + mirrored into the head window + expect(paginator.getItem('m1')?.message_id).toBe('m1'); + expect(paginator.latestItems.map((r) => r.message_id)).toEqual(['m1', 'm2']); + expect(paginator.hasMoreTail).toBe(true); + expect(paginator.cursor?.tailward).toBe('next-cursor'); + }); + + it('appends forward pages and dedupes by message_id', async () => { + const paginator = new ReminderPaginator(client, { pageSize: 2 }); + const spy = vi.spyOn(client, 'queryReminders'); + spy.mockResolvedValueOnce( + response( + [ + makeReminder('m1', '2020-01-01T00:00:00.000Z'), + makeReminder('m2', '2020-01-02T00:00:00.000Z'), + ], + { next: 'c1' }, + ), + ); + await paginator.executeQuery({}); + + spy.mockResolvedValueOnce( + response([ + makeReminder('m2', '2020-01-02T00:00:00.000Z'), // duplicate + makeReminder('m3', '2020-01-03T00:00:00.000Z'), + ]), + ); + await paginator.toTail(); + + expect(paginator.items?.map((r) => r.message_id)).toEqual(['m1', 'm2', 'm3']); + expect(paginator.hasMoreTail).toBe(false); + expect(paginator.cursor?.tailward).toBeNull(); + }); + + it('orders by the requested sort; changing sort resets and re-orders', async () => { + const paginator = new ReminderPaginator(client, { pageSize: 3 }); + const page = [ + makeReminder('m2', '2020-01-02T00:00:00.000Z'), + makeReminder('m1', '2020-01-01T00:00:00.000Z'), + makeReminder('m3', '2020-01-03T00:00:00.000Z'), + ]; + const spy = vi.spyOn(client, 'queryReminders').mockResolvedValue(response(page)); + + // default sort: created_at ascending + await paginator.executeQuery({}); + expect(paginator.items?.map((r) => r.message_id)).toEqual(['m1', 'm2', 'm3']); + + // changing sort resets accumulated pages and re-orders the next load + paginator.sort = { created_at: -1 }; + expect(paginator.items).toBeUndefined(); + spy.mockResolvedValue(response(page)); + await paginator.executeQuery({}); + expect(paginator.items?.map((r) => r.message_id)).toEqual(['m3', 'm2', 'm1']); + }); +}); diff --git a/test/unit/pagination/paginators/UserGroupPaginator.test.ts b/test/unit/pagination/paginators/UserGroupPaginator.test.ts new file mode 100644 index 000000000..90c38c427 --- /dev/null +++ b/test/unit/pagination/paginators/UserGroupPaginator.test.ts @@ -0,0 +1,121 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { StreamChat, UserGroupResponse } from '../../../../src'; +import { UserGroupPaginator } from '../../../../src/pagination/paginators/UserGroupPaginator'; +import { getClientWithUser } from '../../test-utils/getClient'; + +const makeGroup = (id: string, createdAt: string): UserGroupResponse => ({ + id, + name: id, + created_at: createdAt, + updated_at: createdAt, +}); + +const response = (groups: UserGroupResponse[]) => ({ duration: '', user_groups: groups }); + +describe('UserGroupPaginator', () => { + let client: StreamChat; + + beforeEach(() => { + client = getClientWithUser({ id: 'user' }); + }); + + it('stores results in interval storage (index-addressable, latestItems populated)', async () => { + const paginator = new UserGroupPaginator(client, { pageSize: 2 }); + vi.spyOn(client, 'queryUserGroups').mockResolvedValue( + response([ + makeGroup('a', '2020-01-01T00:00:00.000Z'), + makeGroup('b', '2020-01-02T00:00:00.000Z'), + ]), + ); + + await paginator.executeQuery({}); + + expect(paginator.items?.map((g) => g.id)).toEqual(['a', 'b']); + // interval storage: items are now resolvable by id and mirrored into the head window + expect(paginator.getItem('a')?.id).toBe('a'); + expect(paginator.getItem('b')?.id).toBe('b'); + expect(paginator.latestItems.map((g) => g.id)).toEqual(['a', 'b']); + // full page -> more forward; backward pagination is disabled for this listing + expect(paginator.hasMoreTail).toBe(true); + expect(paginator.hasMoreHead).toBe(false); + }); + + it('appends forward pages and stops at a short (final) page', async () => { + const paginator = new UserGroupPaginator(client, { pageSize: 2 }); + const spy = vi.spyOn(client, 'queryUserGroups'); + spy.mockResolvedValueOnce( + response([ + makeGroup('a', '2020-01-01T00:00:00.000Z'), + makeGroup('b', '2020-01-02T00:00:00.000Z'), + ]), + ); + await paginator.executeQuery({}); + expect(paginator.hasMoreTail).toBe(true); + + spy.mockResolvedValueOnce(response([makeGroup('c', '2020-01-03T00:00:00.000Z')])); + await paginator.toTail(); + + expect(paginator.items?.map((g) => g.id)).toEqual(['a', 'b', 'c']); + expect(paginator.hasMoreTail).toBe(false); + expect(paginator.cursor?.tailward).toBeNull(); + // the forward request carried the cursor derived from the previous last item + expect(spy).toHaveBeenLastCalledWith( + expect.objectContaining({ + id_gt: 'b', + created_at_gt: '2020-01-02T00:00:00.000Z', + }), + ); + }); + + it('dedupes by id when a group is returned again', async () => { + const paginator = new UserGroupPaginator(client, { pageSize: 2 }); + const spy = vi.spyOn(client, 'queryUserGroups'); + spy.mockResolvedValueOnce( + response([ + makeGroup('a', '2020-01-01T00:00:00.000Z'), + makeGroup('b', '2020-01-02T00:00:00.000Z'), + ]), + ); + await paginator.executeQuery({}); + + spy.mockResolvedValueOnce( + response([ + makeGroup('b', '2020-01-02T00:00:00.000Z'), // duplicate + makeGroup('c', '2020-01-03T00:00:00.000Z'), + ]), + ); + await paginator.toTail(); + + expect(paginator.items?.map((g) => g.id)).toEqual(['a', 'b', 'c']); + }); + + it('orders by created_at/id via the comparator even if the server returns out of order', async () => { + const paginator = new UserGroupPaginator(client, { pageSize: 3 }); + vi.spyOn(client, 'queryUserGroups').mockResolvedValue( + response([ + makeGroup('b', '2020-01-02T00:00:00.000Z'), + makeGroup('a', '2020-01-01T00:00:00.000Z'), + makeGroup('c', '2020-01-03T00:00:00.000Z'), + ]), + ); + + await paginator.executeQuery({}); + + expect(paginator.items?.map((g) => g.id)).toEqual(['a', 'b', 'c']); + }); + + it('does not paginate backward (headward is exhausted)', async () => { + const paginator = new UserGroupPaginator(client, { pageSize: 2 }); + const spy = vi + .spyOn(client, 'queryUserGroups') + .mockResolvedValue(response([makeGroup('a', '2020-01-01T00:00:00.000Z')])); + await paginator.executeQuery({}); + spy.mockClear(); + + await paginator.toHead(); + + expect(spy).not.toHaveBeenCalled(); + expect(paginator.hasMoreHead).toBe(false); + }); +}); diff --git a/test/unit/threads.test.ts b/test/unit/threads.test.ts index 3dc4b927b..9825f1b4a 100644 --- a/test/unit/threads.test.ts +++ b/test/unit/threads.test.ts @@ -168,6 +168,19 @@ describe('Threads 2.0', () => { expect(thread.messagePaginator.isInitialized).to.be.false; }); + it('seeds the reply paginator lastMessageAt from the thread last_message_at', () => { + const thread = createTestThread({ + latest_replies: [], + reply_count: 0, + last_message_at: '2030-01-01T00:00:00.000Z', + }); + // The server floor seeds the sort key even with no replies loaded to display. + expect(thread.messagePaginator.lastMessageAt?.getTime()).to.equal( + new Date('2030-01-01T00:00:00.000Z').getTime(), + ); + expect(thread.messagePaginator.latestMessage).to.be.null; + }); + it('initializes properly without threadData', () => { const thread = createMinimalThread(); const state = thread.state.getLatestValue(); From b71a920e6f153edfd22cd013ae30f6ec2d177b69 Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 22 Jul 2026 15:55:16 +0200 Subject: [PATCH 24/25] fix(pagination): keep the same MessagePaginator.lastMessage updated with WS events --- CLAUDE.md | 2 +- docs/breaking-changes-v14-v15.md | 46 +++-- src/CooldownTimer.ts | 2 +- src/channel.ts | 17 +- .../MessageDeliveryReporter.ts | 2 +- src/offline-support/offline_support_api.ts | 2 +- src/pagination/paginators/BasePaginator.ts | 6 +- .../paginators/MessageIntervalPaginator.ts | 2 +- src/pagination/paginators/MessagePaginator.ts | 86 +++++++-- .../typescript/response-generators/channel.js | 2 +- test/unit/channel.test.js | 22 +-- .../MessageDeliveryReporter.test.ts | 2 +- .../offline_support_api.test.ts | 4 +- .../paginators/BasePaginator.test.ts | 10 +- .../paginators/ChannelPaginator.test.ts | 10 +- .../paginators/MessagePaginator.test.ts | 178 ++++++++++++++---- .../paginators/ReminderPaginator.test.ts | 2 +- .../paginators/UserGroupPaginator.test.ts | 4 +- test/unit/threads.test.ts | 2 +- 19 files changed, 289 insertions(+), 112 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c565ff036..682312197 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,7 +52,7 @@ This is a single-package SDK with **no monorepo**. The public surface is everyth ### Module map of `src/` - **`client.ts` — `StreamChat` facade.** ~5k-line class. Prefer `StreamChat.getInstance(key, secret?, options?)` — the constructor exists for advanced uses but `getInstance` is what `connectUser` warnings and most docs assume. Owns: the axios instance, WS connection lifecycle, `TokenManager`, and a registry of subsystem managers (`threads`, `polls`, `notifications`, `reminders`, `moderation`, `uploadManager`, `messageDeliveryReporter`, plus an optional `offlineDb` injected via `setOfflineDBApi`). New REST endpoints are added here as methods that call `axiosInstance` and return a type from `types.ts`. -- **`channel.ts` (~2.5k lines) + `channel_state.ts` (~1.1k) + `channel_manager.ts` + `channel_batch_updater.ts`** — per-channel object, its in-memory state, and the manager that orchestrates collections of channels (query/sort/filter, pagination, archived/pinned handling). **Messages are NOT stored on `channel.state`.** The message list, thread replies, and pinned messages each live in a paginator — `channel.messagePaginator`, `thread.messagePaginator`, and `channel.pinnedMessagesPaginator` — which are the single source of truth (interval storage + a canonical `ItemIndex`). Read them via `channel.messagePaginator.state.items` / `.getItem(id)` / `.latestItem` (newest loaded item), and mutate via the paginator (`ingestItem` / `removeItem`), never a legacy `channel.state.addMessageSorted()` / `state.messages` (removed). `channel.state.last_message_at` was **removed**; the channel's latest-message timestamp lives on `channel.messagePaginator.lastMessageAt` (its `aggregateState` store — seeded from `ChannelResponse.last_message_at`, then advanced monotonically as messages are ingested). See `docs/breaking-changes-v14-v15.md`. +- **`channel.ts` (~2.5k lines) + `channel_state.ts` (~1.1k) + `channel_manager.ts` + `channel_batch_updater.ts`** — per-channel object, its in-memory state, and the manager that orchestrates collections of channels (query/sort/filter, pagination, archived/pinned handling). **Messages are NOT stored on `channel.state`.** The message list, thread replies, and pinned messages each live in a paginator — `channel.messagePaginator`, `thread.messagePaginator`, and `channel.pinnedMessagesPaginator` — which are the single source of truth (interval storage + a canonical `ItemIndex`). Read them via `channel.messagePaginator.state.items` / `.getItem(id)` / `.headmostItem` (newest loaded item), and mutate via the paginator (`ingestItem` / `removeItem`), never a legacy `channel.state.addMessageSorted()` / `state.messages` (removed). `channel.state.last_message_at` was **removed**; the channel's latest-message timestamp lives on `channel.messagePaginator.lastMessageAt` (its `aggregateState` store — seeded from `ChannelResponse.last_message_at`, then advanced monotonically as messages are ingested). See `docs/breaking-changes-v14-v15.md`. - **`connection.ts` (`StableWSConnection`) + `connection_fallback.ts` (`WSConnectionFallback`)** — realtime transport. Primary WS implementation does its own 25s ping / 35s health-check loop and reconnects on close/error/offline events; the fallback long-polls over HTTP. The client picks between them based on first-connect outcome; both emit `connection.changed` / `transport.changed` events into the client's local event bus. - **`store.ts` — `StateStore`.** Reactive primitive (see "State and subscription patterns" below). - **`signing.ts` — webhook + token helpers.** Server-side primitives `verifyAndParseWebhook`, `parseSqs`, `parseSns`, `verifySignature` (recent CHA-3071 added compressed-payload support). These are re-exported through `client.ts`. **The HMAC is always computed over the uncompressed JSON bytes** — gzip detection uses the `1f 8b` magic bytes, not headers, so the same handler works whether your platform middleware auto-decompressed or not. `CheckSignature` is deprecated in favor of `verifySignature` purely to fix parameter order; new code should use `verifySignature(body, signature, secret)`. diff --git a/docs/breaking-changes-v14-v15.md b/docs/breaking-changes-v14-v15.md index dd83f0fb5..ee8052081 100644 --- a/docs/breaking-changes-v14-v15.md +++ b/docs/breaking-changes-v14-v15.md @@ -46,7 +46,7 @@ Methods: `addMessageSorted`, `addMessagesSorted`, `removeMessage`, `findMessage` | Before (v14) | After (v15) | | ------------------------------------------------------------- | ------------------------------------------------------------------------------------ | | `channel.state.messages` | `channel.messagePaginator.state.items` (reactive) / `channel.messagePaginator.items` | -| `channel.state.latestMessages` | `channel.messagePaginator.latestItems` / `.latestMessage` | +| `channel.state.latestMessages` | `channel.messagePaginator.headItems` / `.lastMessage` | | `channel.state.messagePagination` | `channel.messagePaginator.state` (`hasMoreHead` / `hasMoreTail` / `cursor`) | | `channel.state.threads[parentId]` | `thread.messagePaginator.state.items` (resolve the `Thread` via `client.threads`) | | `channel.state.pinnedMessages` | `channel.pinnedMessagesPaginator.state.items` | @@ -94,25 +94,47 @@ seededLastMessageAt }`). It returns `max(lastMessage?.created_at, seededLastMess - **Tracking relocated:** `MessageIntervalPaginator`'s `state.latestMessageId` and the `latestMessage` getter (id resolved from the pagination `state`) were replaced. The tracked latest now lives on `MessagePaginator.aggregateState.lastMessage`, advanced on every ingest. - `MessagePaginator.latestMessage` remains as a convenience getter but now reads `aggregateState`. + `MessagePaginator.lastMessage` remains as a convenience getter but now reads `aggregateState`. This matters for reactivity: pagination `state` only emits when the **active** interval is impacted, so a WS message landing in the (non-active) head interval would not notify a `state`-derived latest; `aggregateState` is written directly on each advance and emits regardless — subscribe to it - (e.g. for a channel/thread list item's latest-message display). + (e.g. for a channel/thread list item's latest-message display). `aggregateState.lastMessage` is a + LIVE reference: refreshed in place on edit/soft-delete/reaction of the current latest and recomputed + on hard-remove, and it honors `skip_last_msg_update_for_system_msgs` (system messages neither + reorder a channel nor become its displayed latest). A consumer that previously showed the unfiltered + newest message (`headmostItem`) as the channel-list preview will now skip system messages under that + config — a deliberate behavior change so the preview and the channel's sort position agree. -## `PaginatorState.headItems` and `BasePaginator.headItems` removed — use `latestItems` / `latestItem` +## Newest-loaded window is exposed as computed getters `headItems` / `headmostItem` **Area:** `BasePaginator` · **Status:** implemented -The reactive `headItems` field on `PaginatorState` and the `paginator.headItems` getter were removed. -They materialized a reference-stable copy of the newest-loaded window into pagination state, but had -no consumer. +The newest-loaded window is exposed as **computed getters** on the paginator — `paginator.headItems` +(the window; `[]` before the first load) and `paginator.headmostItem` (its single newest item), +derived from the intervals on read. -- **Migrate:** use the computed getters `paginator.latestItems` (newest loaded window — returns `[]` - instead of `undefined` before the first load) and `paginator.latestItem` (single newest loaded - item). These are derived directly from the intervals and are unchanged. -- **Why:** dead state. `latestItems`/`latestItem` already provide the value, computed from the - intervals; the materialized mirror only added a preprocessor and a `PaginatorState` field. +- **Migrate:** the v14 `channel.state.latestMessages` reactive array becomes + `channel.messagePaginator.headItems`. Because these are getters, **not** `PaginatorState` fields, + they are not subscribable via `useStateStore(paginator.state, (s) => s.headItems)` — read them + directly, or subscribe to `channel.messagePaginator.aggregateState` for the reactive last-message + signal. +- **Why:** the value is derivable from the intervals on demand, so it does not need to be materialized + (and re-emitted) into pagination state. + +## `Channel.lastMessage()` removed — use `channel.messagePaginator.headmostItem` + +**Area:** `Channel` · **Status:** implemented + +`channel.lastMessage()` was removed. It returned the newest loaded message (the head edge of the +message paginator's latest window); read `channel.messagePaginator.headmostItem` directly instead. + +- **Migrate:** `channel.lastMessage()` → `channel.messagePaginator.headmostItem`. +- **Note:** `headmostItem` is the newest _loaded_ message, **unfiltered** (includes system messages) — + distinct from `channel.messagePaginator.lastMessage`, the filtered chronological latest (honors + `skip_last_msg_update_for_system_msgs`) that backs `lastMessageAt`. Use `headmostItem` for "the newest + message on screen"; use `lastMessage` / `lastMessageAt` for channel-list ordering. +- **Why:** it was a thin wrapper over `headmostItem`, and sharing the name `lastMessage` with the + differently-filtered paginator getter was misleading. ## `ChannelState.isUpToDate` / `setIsUpToDate` removed diff --git a/src/CooldownTimer.ts b/src/CooldownTimer.ts index 0840a645f..1421461de 100644 --- a/src/CooldownTimer.ts +++ b/src/CooldownTimer.ts @@ -102,7 +102,7 @@ export class CooldownTimer extends WithSubscriptions { const canSkipCooldown = (own_capabilities ?? []).includes('skip-slow-mode'); const ownLatestMessageDate = this.findOwnLatestMessageDate({ - messages: this.channel.messagePaginator.latestItems, + messages: this.channel.messagePaginator.headItems, }); if ( diff --git a/src/channel.ts b/src/channel.ts index e50ffbbdd..ac81a095d 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -1382,17 +1382,6 @@ export class Channel { return this.getClient().user?.privacy_settings?.typing_indicators?.enabled ?? true; } - /** - * lastMessage - return the last message, takes into account that last few messages might not be perfectly sorted - * - * @return {ReturnType | undefined} Description - */ - lastMessage(): LocalMessage | undefined { - // The paginator keeps its latest (head) window sorted, so its head edge is the newest message. - // (Replaces the legacy "slice last 5 of state.latestMessages + re-sort" heuristic.) - return this.messagePaginator.latestItem; - } - /** * markRead - Send the mark read event for this user, only works if the `read_events` setting is enabled. Syncs the message delivery report candidates local state. * @@ -1459,7 +1448,7 @@ export class Channel { channel_type: this.type, cid: this.cid, created_at: new Date().toISOString(), - last_read_message_id: this.lastMessage()?.id, + last_read_message_id: this.messagePaginator.headmostItem?.id, team: this.data?.team, type: 'message.read_locally', user: client.user, @@ -1681,7 +1670,7 @@ export class Channel { countUnread(lastRead?: Date | null) { if (!lastRead) return this.state.unreadCount; let count = 0; - const latestMessages = this.messagePaginator.latestItems; + const latestMessages = this.messagePaginator.headItems; for (let i = 0; i < latestMessages.length; i += 1) { const message = latestMessages[i]; if (message.created_at > lastRead && this._countMessageAsUnread(message)) { @@ -1701,7 +1690,7 @@ export class Channel { const userID = this.getClient().userID; let count = 0; - const latestMessages = this.messagePaginator.latestItems; + const latestMessages = this.messagePaginator.headItems; for (let i = 0; i < latestMessages.length; i += 1) { const message = latestMessages[i]; if ( diff --git a/src/messageDelivery/MessageDeliveryReporter.ts b/src/messageDelivery/MessageDeliveryReporter.ts index 6bf1a5b20..214055184 100644 --- a/src/messageDelivery/MessageDeliveryReporter.ts +++ b/src/messageDelivery/MessageDeliveryReporter.ts @@ -134,7 +134,7 @@ export class MessageDeliveryReporter { let key: string | undefined = undefined; if (isChannel(collection)) { - latestMessages = collection.messagePaginator.latestItems; + latestMessages = collection.messagePaginator.headItems; const ownReadState = collection.state.read[ownUserId] ?? {}; lastReadAt = ownReadState?.last_read; lastDeliveredAt = ownReadState?.last_delivered_at; diff --git a/src/offline-support/offline_support_api.ts b/src/offline-support/offline_support_api.ts index a688de79a..8ee051107 100644 --- a/src/offline-support/offline_support_api.ts +++ b/src/offline-support/offline_support_api.ts @@ -1294,7 +1294,7 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { timestampChanged: true, }); } - channel.messagePaginator.trackLatestMessage(formatMessage(newMessage)); + channel.messagePaginator.trackLastMessage(formatMessage(newMessage)); } return newMessageResponse; } diff --git a/src/pagination/paginators/BasePaginator.ts b/src/pagination/paginators/BasePaginator.ts index 52bac95ce..b8072a6a2 100644 --- a/src/pagination/paginators/BasePaginator.ts +++ b/src/pagination/paginators/BasePaginator.ts @@ -539,17 +539,17 @@ export abstract class BasePaginator { * older window as "latest" (best effort). Use for "latest"-derived reads: last message, unread * counting, delivery candidates, channel-list previews. */ - get latestItems(): T[] { + get headItems(): T[] { if (!this.usesItemIntervalStorage) return this.items ?? []; const head = this.getHeadIntervalFromSortedIntervals(this.itemIntervals); return head ? this.intervalToItems(head) : []; } /** - * The single newest loaded item — the head pagination edge of {@link BasePaginator.latestItems}. + * The item on the head edge of the head pagination interval (of {@link BasePaginator.headItems}). * `undefined` when nothing is loaded. */ - get latestItem(): T | undefined { + get headmostItem(): T | undefined { if (!this.usesItemIntervalStorage) return this.items?.[0]; const head = this.getHeadIntervalFromSortedIntervals(this.itemIntervals); return head ? (this.getIntervalPaginationEdges(head)?.head ?? undefined) : undefined; diff --git a/src/pagination/paginators/MessageIntervalPaginator.ts b/src/pagination/paginators/MessageIntervalPaginator.ts index b8068e333..56e219946 100644 --- a/src/pagination/paginators/MessageIntervalPaginator.ts +++ b/src/pagination/paginators/MessageIntervalPaginator.ts @@ -949,7 +949,7 @@ export class MessageIntervalPaginator extends BasePaginator< timestampMs: number, exactTsMatch = false, ): LocalMessage | null => { - const items = this.latestItems; // ascending by created_at + const items = this.headItems; // ascending by created_at if (!items.length) return null; // Resolve the last message created AT OR BEFORE `timestampMs` (floor). The sole caller is // read/delivered cursor resolution (MessageReceiptsTracker): the cursor carries the timestamp of diff --git a/src/pagination/paginators/MessagePaginator.ts b/src/pagination/paginators/MessagePaginator.ts index 0f83fa64e..76e470f58 100644 --- a/src/pagination/paginators/MessagePaginator.ts +++ b/src/pagination/paginators/MessagePaginator.ts @@ -35,11 +35,16 @@ export { MessageIntervalPaginator } from './MessageIntervalPaginator'; */ export type MessagePaginatorAggregateState = { /** - * The newest channel-relevant message loaded/received (monotonic by `created_at`), for display — - * e.g. a thread-list item's latest-reply avatar. `null` until one is ingested. Lives here, NOT - * derived from pagination `state`, so it stays reactive when a WS message lands in the head interval - * while an older window is active — the pagination store only emits when the *active* interval is - * impacted (see `BasePaginator.ingestItem`), so a `state`-derived latest would go stale in that case. + * The newest channel-relevant message, for display (e.g. a channel/thread list item's last-message + * preview or latest-reply avatar). A LIVE reference: it advances to a strictly newer message, is + * refreshed in place when that message is edited/soft-deleted/reacted-to, and is recomputed to the + * next newest when it is hard-removed. Respects `shouldAdvanceLastMessage` (skips system messages + * per `skip_last_msg_update_for_system_msgs`, and thread-only replies). `null` until one is ingested. + * + * Lives here, NOT derived from pagination `state`, so it stays reactive when a WS message lands in + * the head interval while an older window is active — the pagination store only emits when the + * *active* interval is impacted (see `BasePaginator.ingestItem`), so a `state`-derived latest would + * go stale in that case. */ lastMessage: LocalMessage | null; /** @@ -140,7 +145,7 @@ export class MessagePaginator extends MessageIntervalPaginator { /** * Channel-list sort key: the later of the newest loaded message's `created_at` and the server seed. - * **Derived** (never stored) so it cannot drift from {@link latestMessage}. `null` until seeded or a + * **Derived** (never stored) so it cannot drift from {@link lastMessage}. `null` until seeded or a * message is ingested. */ get lastMessageAt(): Date | null { @@ -158,7 +163,7 @@ export class MessagePaginator extends MessageIntervalPaginator { * of {@link aggregateState}; subscribe to `aggregateState` for reactivity. `null` until a message is * ingested (a server-only seed advances {@link lastMessageAt} but leaves this `null`). */ - get latestMessage(): LocalMessage | null { + get lastMessage(): LocalMessage | null { return this.aggregateState.getLatestValue().lastMessage; } @@ -167,13 +172,13 @@ export class MessagePaginator extends MessageIntervalPaginator { * the base rule (not shadowed) this excludes thread-only replies (a reply with a `parent_id` that is * not shown in the channel is not part of the channel list) and, when the channel is configured with * `skip_last_msg_update_for_system_msgs`, system messages. Mirrors the legacy - * `Channel._trackLatestMessage` skip logic. + * `Channel._trackLastMessage` skip logic. * * These exclusions are specific to the MAIN channel list. A reply list (thread paginator, which has * a `parentMessageId`) is made ENTIRELY of thread replies — there the newest reply is exactly the * "latest message", so the exclusions are skipped and only the base rule applies. */ - protected shouldAdvanceLatestMessage(message: LocalMessage): boolean { + protected shouldAdvanceLastMessage(message: LocalMessage): boolean { if (message.shadowed) return false; if (this.parentMessageId) return true; const isThreadOnlyReply = !!message.parent_id && !message.show_in_channel; @@ -186,7 +191,7 @@ export class MessagePaginator extends MessageIntervalPaginator { /** * Monotonically advance {@link lastMessageAt} to `message.created_at` when it is newer than the - * current value and {@link shouldAdvanceLatestMessage} permits it. Writes the dedicated + * current value and {@link shouldAdvanceLastMessage} permits it. Writes the dedicated * {@link aggregateState} store (not `state`), so it is safe to call from inside a `state.next` * updater (`ingestPage`). Returns `true` when the value moved. * @@ -194,14 +199,22 @@ export class MessagePaginator extends MessageIntervalPaginator { * in-window ingest — e.g. offline pending-message replay, where the sent message has not yet been * ingested via the `message.new` event. */ - trackLatestMessage(message: LocalMessage): boolean { - if (!this.shouldAdvanceLatestMessage(message)) return false; + trackLastMessage(message: LocalMessage): boolean { + if (!this.shouldAdvanceLastMessage(message)) return false; const incoming = getMessageCreatedAtTimestamp(message); if (incoming === null) return false; - // Guard against the current display message's own timestamp — NOT `lastMessageAt` (which the - // server seed can inflate). Otherwise, a seed newer than the loaded window would reject the very - // message it was derived from, and `lastMessage` would never populate. const current = this.aggregateState.getLatestValue().lastMessage; + // Refresh in place when this IS the current latest being updated — an edit, soft-delete, + // reaction, or quoted-message update all re-ingest the same id (same `created_at`). This keeps + // `lastMessage` a LIVE reference (so a preview shows "deleted"/edited text), and because + // `aggregateState` emits regardless of the active interval, it stays reactive off-window. + if (current && current.id === message.id) { + this.aggregateState.partialNext({ lastMessage: message }); + return true; + } + // Otherwise only advance to a strictly newer message. Guard against the current message's own + // timestamp — NOT `lastMessageAt` (which the server seed can inflate); otherwise a seed newer than + // the loaded window would reject the very message it was derived from. const currentTs = current ? getMessageCreatedAtTimestamp(current) : null; if (currentTs !== null && incoming <= currentTs) return false; this.aggregateState.partialNext({ lastMessage: message }); @@ -228,7 +241,7 @@ export class MessagePaginator extends MessageIntervalPaginator { // of items that no longer match). The advance writes the separate `aggregateState` store, so it // is independent of `super`'s `state`/index mutations. if (this.matchesFilter(item)) { - this.trackLatestMessage(item); + this.trackLastMessage(item); } return super.ingestItem(item); } @@ -240,11 +253,50 @@ export class MessagePaginator extends MessageIntervalPaginator { // Advance from each page item; the monotonic max over the page yields the newest. Comparison is // by `created_at` against `aggregateState` (no index lookup), so order vs `super` is irrelevant. if (params.page?.length) { - for (const item of params.page) this.trackLatestMessage(item); + for (const item of params.page) this.trackLastMessage(item); } return interval; } + removeItem( + params: Parameters[0], + ): ReturnType { + const removedId = + params.id ?? (params.item ? this.getItemId(params.item) : undefined); + const wasLatest = + !!removedId && this.aggregateState.getLatestValue().lastMessage?.id === removedId; + const result = super.removeItem(params); + if (wasLatest) { + // The tracked latest was hard-removed; fall back to the newest still-loaded message that + // passes the filter. `trackLastMessage` cannot do this (it only advances), so recompute here. + this.aggregateState.partialNext({ lastMessage: this.recomputeLastMessage() }); + } + return result; + } + + /** + * The still-loaded message with the greatest `created_at` that passes + * {@link shouldAdvanceLastMessage} (skips system / thread-only per config), from the newest-loaded + * window. Used to recompute the tracked latest after the current one is removed. `null` when nothing + * loaded qualifies. + * + * "Latest" is defined by `created_at` (matching {@link trackLastMessage}), NOT by the paginator's + * display order — so this compares timestamps rather than assuming a position, and stays correct + * whatever `itemOrder` / `requestSort` are configured to. + */ + private recomputeLastMessage(): LocalMessage | null { + let latest: LocalMessage | null = null; + let latestTimestamp = -Infinity; + for (const item of this.headItems) { + if (!this.shouldAdvanceLastMessage(item)) continue; + const timestamp = getMessageCreatedAtTimestamp(item); + if (timestamp === null || timestamp <= latestTimestamp) continue; + latest = item; + latestTimestamp = timestamp; + } + return latest; + } + /** * (Re)seed the unread state snapshot from the current own read state. * diff --git a/test/typescript/response-generators/channel.js b/test/typescript/response-generators/channel.js index 9296bf4e4..588636fcd 100644 --- a/test/typescript/response-generators/channel.js +++ b/test/typescript/response-generators/channel.js @@ -141,7 +141,7 @@ async function lastMessage() { await channel.sendMessage({ text: 'Hello World' }); await channel.sendMessage({ text: 'Hello World...again' }); - const message = await channel.lastMessage(); + const message = await channel.messagePaginator.headmostItem; delete message.__html; // __html is deprecated and removed from the types return message; } diff --git a/test/unit/channel.test.js b/test/unit/channel.test.js index 06f9afa27..d8bfb36d5 100644 --- a/test/unit/channel.test.js +++ b/test/unit/channel.test.js @@ -15,7 +15,7 @@ import { formatMessage, generateUUIDv4 as uuidv4 } from '../../src/utils'; import { describe, beforeEach, afterEach, it, expect, vi } from 'vitest'; // Seed the channel's messagePaginator "latest" (head) window from raw generated messages. -// The unread/last-message readers now source from `messagePaginator.latestItems`/`latestItem`, +// The unread/last-message readers now source from `messagePaginator.headItems`/`headmostItem`, // so tests populate the paginator (formatted) rather than the legacy `state.addMessagesSorted`. const seedLatestWindow = (channel, messages) => channel.messagePaginator.ingestPage({ @@ -938,7 +938,7 @@ describe('Channel _handleChannelEvent', function () { ].map(generateMsg); seedLatestWindow(channel, messages); - expect(channel.messagePaginator.latestItems.length).to.be.equal(3); + expect(channel.messagePaginator.headItems.length).to.be.equal(3); channel._handleChannelEvent({ type: 'channel.truncated', @@ -948,7 +948,7 @@ describe('Channel _handleChannelEvent', function () { }, }); - expect(channel.messagePaginator.latestItems.length).to.be.equal(0); + expect(channel.messagePaginator.headItems.length).to.be.equal(0); }); it('message.truncate clears messagePaginator unread snapshot', function () { @@ -996,7 +996,7 @@ describe('Channel _handleChannelEvent', function () { ].map(generateMsg); seedLatestWindow(channel, messages); - expect(channel.messagePaginator.latestItems.length).to.be.equal(3); + expect(channel.messagePaginator.headItems.length).to.be.equal(3); channel._handleChannelEvent({ type: 'channel.truncated', @@ -1006,7 +1006,7 @@ describe('Channel _handleChannelEvent', function () { }, }); - expect(channel.messagePaginator.latestItems.length).to.be.equal(2); + expect(channel.messagePaginator.headItems.length).to.be.equal(2); }); it('prunes pinned messages older than the cutoff on a partial channel.truncated', () => { @@ -1994,7 +1994,7 @@ describe('Channel _handleChannelEvent', function () { expect(Object.keys(channel.state.members).length).to.be.eq(1); expect(Object.keys(channel.state.watchers).length).to.be.eq(1); expect(Object.keys(channel.state.read).length).to.be.eq(1); - expect(channel.messagePaginator.latestItems.length).to.be.eq(1); + expect(channel.messagePaginator.headItems.length).to.be.eq(1); expect(channel.state.watcher_count).to.be.eq(5); }); @@ -2605,7 +2605,7 @@ describe('Channel lastMessage', async () => { generateMsg({ date: latestMessageDate }), ]); - expect(channel.lastMessage().created_at.getTime()).to.be.equal( + expect(channel.messagePaginator.headmostItem.created_at.getTime()).to.be.equal( new Date(latestMessageDate).getTime(), ); }); @@ -2619,7 +2619,7 @@ describe('Channel lastMessage', async () => { generateMsg({ date: '2018-01-01T00:00:00' }), ]); - expect(channel.lastMessage().created_at.getTime()).to.be.equal( + expect(channel.messagePaginator.headmostItem.created_at.getTime()).to.be.equal( new Date(latestMessageDate).getTime(), ); }); @@ -2643,7 +2643,7 @@ describe('Channel lastMessage', async () => { setActive: false, }); - expect(channel.lastMessage().created_at.getTime()).to.be.equal( + expect(channel.messagePaginator.headmostItem.created_at.getTime()).to.be.equal( new Date(latestMessageDate).getTime(), ); }); @@ -2678,7 +2678,7 @@ describe('Channel last_message_at', () => { channel.state = new ChannelState(channel); }); - const track = (msg) => channel.messagePaginator.trackLatestMessage(formatMessage(msg)); + const track = (msg) => channel.messagePaginator.trackLastMessage(formatMessage(msg)); it('advances monotonically as messages are tracked', () => { expect(channel.messagePaginator.lastMessageAt).to.be.null; @@ -2840,7 +2840,7 @@ describe('Channel.query', async () => { expect(channel.messagePaginator.items).to.have.length( DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE, ); - expect(channel.messagePaginator.latestItem).to.not.equal(undefined); + expect(channel.messagePaginator.headmostItem).to.not.equal(undefined); mock.restore(); }); diff --git a/test/unit/messageDelivery/MessageDeliveryReporter.test.ts b/test/unit/messageDelivery/MessageDeliveryReporter.test.ts index 94c951761..3228388aa 100644 --- a/test/unit/messageDelivery/MessageDeliveryReporter.test.ts +++ b/test/unit/messageDelivery/MessageDeliveryReporter.test.ts @@ -23,7 +23,7 @@ const otherUser = { const mkMsg = (id: string, at: string | number | Date) => ({ id, created_at: new Date(at) }) as any; -// The delivery reporter now derives the latest message from `channel.messagePaginator.latestItems`, +// The delivery reporter now derives the latest message from `channel.messagePaginator.headItems`, // so tests seed the paginator's latest (head) window instead of assigning `channel.state.latestMessages`. const setLatest = (channel: Channel, msgs: ReturnType[]) => { channel.messagePaginator.clearStateAndCache(); diff --git a/test/unit/offline-support/offline_support_api.test.ts b/test/unit/offline-support/offline_support_api.test.ts index a8b450200..af118c3a9 100644 --- a/test/unit/offline-support/offline_support_api.test.ts +++ b/test/unit/offline-support/offline_support_api.test.ts @@ -2103,7 +2103,7 @@ describe('OfflineSupportApi', () => { _deleteReaction: vi.fn(), _createDraft: vi.fn(), _deleteDraft: vi.fn(), - messagePaginator: { trackLatestMessage: vi.fn() }, + messagePaginator: { trackLastMessage: vi.fn() }, } as unknown as Channel; _updateMessageSpy = vi @@ -2180,7 +2180,7 @@ describe('OfflineSupportApi', () => { await offlineDb['executeTask']({ task }, true); expect(mockChannel._sendMessage).toHaveBeenCalledWith(...task.payload); - expect(mockChannel.messagePaginator.trackLatestMessage).toHaveBeenCalledWith( + expect(mockChannel.messagePaginator.trackLastMessage).toHaveBeenCalledWith( expect.objectContaining({ id: 'msg1' }), ); }); diff --git a/test/unit/pagination/paginators/BasePaginator.test.ts b/test/unit/pagination/paginators/BasePaginator.test.ts index 585c4c17b..b45d03643 100644 --- a/test/unit/pagination/paginators/BasePaginator.test.ts +++ b/test/unit/pagination/paginators/BasePaginator.test.ts @@ -3174,15 +3174,15 @@ describe('BasePaginator', () => { }); }); - describe('latestItems (newest loaded window)', () => { + describe('headItems (newest loaded window)', () => { it('is empty before the first load and mirrors items in flat mode', () => { const paginator = new Paginator(); - expect(paginator.latestItems).toEqual([]); + expect(paginator.headItems).toEqual([]); const loaded = [{ id: 'a' }]; paginator.setItems({ valueOrFactory: loaded }); - expect(paginator.latestItems).toStrictEqual(loaded); + expect(paginator.headItems).toStrictEqual(loaded); }); it('materializes the head window in interval-storage mode', () => { @@ -3199,8 +3199,8 @@ describe('BasePaginator', () => { setActive: true, }); - expect(paginator.latestItems.length).toBeGreaterThan(0); - expect(paginator.latestItems.map((item) => item.id)).toEqual( + expect(paginator.headItems.length).toBeGreaterThan(0); + expect(paginator.headItems.map((item) => item.id)).toEqual( paginator.items?.map((item) => item.id), ); itemIndex.clear(); diff --git a/test/unit/pagination/paginators/ChannelPaginator.test.ts b/test/unit/pagination/paginators/ChannelPaginator.test.ts index 575bd08ee..df81b79c1 100644 --- a/test/unit/pagination/paginators/ChannelPaginator.test.ts +++ b/test/unit/pagination/paginators/ChannelPaginator.test.ts @@ -24,7 +24,7 @@ const user = { id: 'custom-id' }; const setLastMessageAt = (channel: Channel, date: Date | null) => { channel.messagePaginator.clearStateAndCache(); if (date) { - channel.messagePaginator.trackLatestMessage( + channel.messagePaginator.trackLastMessage( formatMessage(generateMsg({ date: date.toISOString() })), ); } @@ -727,7 +727,7 @@ describe('ChannelPaginator', () => { }); describe('interval storage', () => { - it('is index-addressable by cid, populates latestItems, and dedupes across pages', async () => { + it('is index-addressable by cid, populates headItems, and dedupes across pages', async () => { const a = new Channel(client, 'type', 'iv-a', {}); const b = new Channel(client, 'type', 'iv-b', {}); let page: Channel[] = [a, b]; @@ -744,7 +744,7 @@ describe('ChannelPaginator', () => { // resolvable by cid + mirrored into the head window (interval storage) expect(paginator.getItem('type:iv-a')).toBe(a); expect(paginator.getItem('type:iv-b')).toBe(b); - expect(paginator.latestItems.map((c) => c.cid).sort()).toEqual([ + expect(paginator.headItems.map((c) => c.cid).sort()).toEqual([ 'type:iv-a', 'type:iv-b', ]); @@ -783,8 +783,8 @@ describe('ChannelPaginator', () => { 'type:oldest', ]); // head edge = index 0 = newest; head window starts with it too - expect(paginator.latestItem?.cid).toBe('type:newest'); - expect(paginator.latestItems[0]?.cid).toBe('type:newest'); + expect(paginator.headmostItem?.cid).toBe('type:newest'); + expect(paginator.headItems[0]?.cid).toBe('type:newest'); }); it('promotes a non-headmost channel to the top on re-ingest without dropping it', async () => { diff --git a/test/unit/pagination/paginators/MessagePaginator.test.ts b/test/unit/pagination/paginators/MessagePaginator.test.ts index e611692ef..0dd4d24de 100644 --- a/test/unit/pagination/paginators/MessagePaginator.test.ts +++ b/test/unit/pagination/paginators/MessagePaginator.test.ts @@ -1388,7 +1388,7 @@ describe('MessagePaginator', () => { // Fewer messages than the requested page size => dataset edges reached both ways. paginator.seedFirstPageSync([msg('m8', '08'), msg('m9', '09')], 100); - expect(paginator.latestItem?.id).toBe('m9'); + expect(paginator.headmostItem?.id).toBe('m9'); expect(paginator.hasMoreHead).toBe(false); expect(paginator.hasMoreTail).toBe(false); }); @@ -1426,7 +1426,7 @@ describe('MessagePaginator', () => { created_at: `2020-01-${day}T00:00:00.000Z`, }); - describe('latestItems / latestItem', () => { + describe('headItems / headmostItem', () => { it('reflect the active head window', () => { const paginator = new MessagePaginator({ channel, itemIndex }); paginator.ingestPage({ @@ -1437,8 +1437,8 @@ describe('MessagePaginator', () => { }); expect(paginator.items?.map((m) => m.id)).toEqual(['m8', 'm9']); - expect(paginator.latestItems.map((m) => m.id)).toEqual(['m8', 'm9']); - expect(paginator.latestItem?.id).toBe('m9'); + expect(paginator.headItems.map((m) => m.id)).toEqual(['m8', 'm9']); + expect(paginator.headmostItem?.id).toBe('m9'); }); it('reflect the head window even while an older window is active (after a jump)', () => { @@ -1455,12 +1455,12 @@ describe('MessagePaginator', () => { }); expect(paginator.items?.map((m) => m.id)).toEqual(['m4', 'm5']); // active window - expect(paginator.latestItems.map((m) => m.id)).toEqual(['m8', 'm9']); // newest window - expect(paginator.latestItem?.id).toBe('m9'); + expect(paginator.headItems.map((m) => m.id)).toEqual(['m8', 'm9']); // newest window + expect(paginator.headmostItem?.id).toBe('m9'); }); it('return the newest loaded window even when it is not flagged isHead (query/hydration seed)', () => { - // The query/hydration seed does not reliably mark a latest page as isHead, so latestItems + // The query/hydration seed does not reliably mark a latest page as isHead, so headItems // uses the head-most *loaded* window rather than requiring the flag. const paginator = new MessagePaginator({ channel, itemIndex }); paginator.ingestPage({ @@ -1468,14 +1468,14 @@ describe('MessagePaginator', () => { setActive: true, }); - expect(paginator.latestItems.map((m) => m.id)).toEqual(['m4', 'm5']); - expect(paginator.latestItem?.id).toBe('m5'); + expect(paginator.headItems.map((m) => m.id)).toEqual(['m4', 'm5']); + expect(paginator.headmostItem?.id).toBe('m5'); }); it('are empty when nothing is loaded', () => { const paginator = new MessagePaginator({ channel, itemIndex }); - expect(paginator.latestItems).toEqual([]); - expect(paginator.latestItem).toBeUndefined(); + expect(paginator.headItems).toEqual([]); + expect(paginator.headmostItem).toBeUndefined(); }); }); @@ -1649,7 +1649,7 @@ describe('MessagePaginator', () => { paginator.ingestItem(msg('m3', '03')); expect(paginator.items?.map((m) => m.id)).toEqual(['m1', 'm2', 'm3']); - expect(paginator.latestItem?.id).toBe('m3'); + expect(paginator.headmostItem?.id).toBe('m3'); }); it('does not inject a newer message into an older active window (viewer scrolled away)', () => { @@ -1909,7 +1909,7 @@ describe('MessagePaginator', () => { }); }); - describe('trackLatestMessage() / lastMessageAt', () => { + describe('trackLastMessage() / lastMessageAt', () => { let skipSystemMessages: boolean; let trackingChannel: Channel; @@ -1940,19 +1940,19 @@ describe('MessagePaginator', () => { seededLastMessageAt: null, }); expect(paginator.lastMessageAt).toBeNull(); - expect(paginator.latestMessage).toBeNull(); + expect(paginator.lastMessage).toBeNull(); }); it('advances lastMessageAt and lastMessage without ingesting a window', () => { const paginator = buildPaginator(); - paginator.trackLatestMessage( + paginator.trackLastMessage( createMessage({ id: 'a', created_at: '2020-01-01T00:00:00.000Z' }), ); expect(paginator.lastMessageAt?.getTime()).toBe(at('2020-01-01T00:00:00.000Z')); // The display message is tracked on aggregateState (reactive off-window), not the visible list. - expect(paginator.latestMessage?.id).toBe('a'); + expect(paginator.lastMessage?.id).toBe('a'); expect(paginator.items).toBeUndefined(); }); @@ -1961,7 +1961,7 @@ describe('MessagePaginator', () => { paginator.seedLastMessageAt('2023-05-03T11:12:53.993Z'); expect(paginator.lastMessageAt?.getTime()).toBe(at('2023-05-03T11:12:53.993Z')); // The server seed has a timestamp but not the message itself. - expect(paginator.latestMessage).toBeNull(); + expect(paginator.lastMessage).toBeNull(); }); it('lastMessageAt is the max of the loaded message and the seed; a seed never blocks the display message', () => { @@ -1969,22 +1969,22 @@ describe('MessagePaginator', () => { // Server says the newest message is far in the future (not yet loaded). paginator.seedLastMessageAt('2030-01-01T00:00:00.000Z'); expect(paginator.lastMessageAt?.getTime()).toBe(at('2030-01-01T00:00:00.000Z')); - expect(paginator.latestMessage).toBeNull(); + expect(paginator.lastMessage).toBeNull(); // A real (older-than-seed) message must still become the display message — the guard is against // the display message's own timestamp, not the seed-inflated lastMessageAt. - paginator.trackLatestMessage( + paginator.trackLastMessage( createMessage({ id: 'a', created_at: '2020-01-01T00:00:00.000Z' }), ); - expect(paginator.latestMessage?.id).toBe('a'); + expect(paginator.lastMessage?.id).toBe('a'); // Sort key stays the max (the seed), so it can never drift below the display message. expect(paginator.lastMessageAt?.getTime()).toBe(at('2030-01-01T00:00:00.000Z')); // Once a message newer than the seed arrives, lastMessageAt follows it. - paginator.trackLatestMessage( + paginator.trackLastMessage( createMessage({ id: 'b', created_at: '2031-01-01T00:00:00.000Z' }), ); - expect(paginator.latestMessage?.id).toBe('b'); + expect(paginator.lastMessage?.id).toBe('b'); expect(paginator.lastMessageAt?.getTime()).toBe(at('2031-01-01T00:00:00.000Z')); }); @@ -1996,7 +1996,7 @@ describe('MessagePaginator', () => { }); stateEmissions = 0; // ignore the synchronous initial subscribe call - paginator.trackLatestMessage( + paginator.trackLastMessage( createMessage({ id: 'a', created_at: '2020-01-01T00:00:00.000Z' }), ); unsubscribe(); @@ -2008,15 +2008,15 @@ describe('MessagePaginator', () => { it('advances monotonically by created_at', () => { const paginator = buildPaginator(); - paginator.trackLatestMessage( + paginator.trackLastMessage( createMessage({ id: 'a', created_at: '2020-01-01T00:00:00.000Z' }), ); - paginator.trackLatestMessage( + paginator.trackLastMessage( createMessage({ id: 'b', created_at: '2019-01-01T00:00:00.000Z' }), ); expect(paginator.lastMessageAt?.getTime()).toBe(at('2020-01-01T00:00:00.000Z')); - paginator.trackLatestMessage( + paginator.trackLastMessage( createMessage({ id: 'c', created_at: '2021-01-01T00:00:00.000Z' }), ); expect(paginator.lastMessageAt?.getTime()).toBe(at('2021-01-01T00:00:00.000Z')); @@ -2025,7 +2025,7 @@ describe('MessagePaginator', () => { it('never advances for a shadowed message', () => { const paginator = buildPaginator(); - paginator.trackLatestMessage( + paginator.trackLastMessage( createMessage({ id: 'a', created_at: '2020-01-01T00:00:00.000Z', @@ -2039,7 +2039,7 @@ describe('MessagePaginator', () => { it('never advances for a thread-only reply, but does for a reply shown in the channel', () => { const paginator = buildPaginator(); - paginator.trackLatestMessage( + paginator.trackLastMessage( createMessage({ id: 'reply', parent_id: 'parent', @@ -2048,7 +2048,7 @@ describe('MessagePaginator', () => { ); expect(paginator.lastMessageAt).toBeNull(); - paginator.trackLatestMessage( + paginator.trackLastMessage( createMessage({ id: 'reply-shown', parent_id: 'parent', @@ -2067,12 +2067,12 @@ describe('MessagePaginator', () => { type: 'system', created_at: '2020-01-01T00:00:00.000Z', }); - skipping.trackLatestMessage(systemMessage); + skipping.trackLastMessage(systemMessage); expect(skipping.lastMessageAt).toBeNull(); skipSystemMessages = false; const tracking = buildPaginator(); - tracking.trackLatestMessage(systemMessage); + tracking.trackLastMessage(systemMessage); expect(tracking.lastMessageAt?.getTime()).toBe(at('2020-01-01T00:00:00.000Z')); }); @@ -2152,7 +2152,7 @@ describe('MessagePaginator', () => { it('resets lastMessageAt on clearStateAndCache()', () => { const paginator = buildPaginator(); - paginator.trackLatestMessage( + paginator.trackLastMessage( createMessage({ id: 'a', created_at: '2020-01-01T00:00:00.000Z' }), ); expect(paginator.lastMessageAt?.getTime()).toBe(at('2020-01-01T00:00:00.000Z')); @@ -2161,5 +2161,119 @@ describe('MessagePaginator', () => { expect(paginator.lastMessageAt).toBeNull(); }); + + it('refreshes lastMessage in place (and emits) when the current latest is edited', () => { + const paginator = buildPaginator(); + paginator.ingestItem( + createMessage({ + id: 'a', + cid: 'channel-id', + created_at: '2020-01-01T00:00:00.000Z', + text: 'hello', + }), + ); + expect(paginator.lastMessage?.text).toBe('hello'); + + let emissions = 0; + const unsubscribe = paginator.aggregateState.subscribe(() => { + emissions += 1; + }); + emissions = 0; + + // edit: same id + same created_at (monotonic guard would otherwise reject it) + paginator.ingestItem( + createMessage({ + id: 'a', + cid: 'channel-id', + created_at: '2020-01-01T00:00:00.000Z', + text: 'edited', + }), + ); + unsubscribe(); + + expect(paginator.lastMessage?.text).toBe('edited'); + expect(emissions).toBe(1); + }); + + it('reflects a soft-delete of the current latest', () => { + const paginator = buildPaginator(); + paginator.ingestItem( + createMessage({ + id: 'a', + cid: 'channel-id', + created_at: '2020-01-01T00:00:00.000Z', + }), + ); + paginator.ingestItem( + createMessage({ + id: 'a', + cid: 'channel-id', + created_at: '2020-01-01T00:00:00.000Z', + type: 'deleted', + deleted_at: '2020-01-02T00:00:00.000Z', + }), + ); + expect(paginator.lastMessage?.type).toBe('deleted'); + }); + + it('recomputes lastMessage to the next newest when the current latest is hard-removed', () => { + const paginator = buildPaginator(); + paginator.ingestItem( + createMessage({ + id: 'a', + cid: 'channel-id', + created_at: '2020-01-01T00:00:00.000Z', + }), + ); + paginator.ingestItem( + createMessage({ + id: 'b', + cid: 'channel-id', + created_at: '2020-01-02T00:00:00.000Z', + }), + ); + expect(paginator.lastMessage?.id).toBe('b'); + + paginator.removeItem({ id: 'b' }); + expect(paginator.lastMessage?.id).toBe('a'); + + paginator.removeItem({ id: 'a' }); + expect(paginator.lastMessage).toBeNull(); + }); + + it('recompute after hard-remove skips a trailing system message (unlike the unfiltered headmostItem)', () => { + skipSystemMessages = true; + const paginator = buildPaginator(); + paginator.ingestItem( + createMessage({ + id: 'm0', + cid: 'channel-id', + created_at: '2020-01-01T00:00:01.000Z', + }), + ); + paginator.ingestItem( + createMessage({ + id: 'm1', + cid: 'channel-id', + created_at: '2020-01-01T00:00:02.000Z', + }), + ); + // A system message is the newest LOADED item, but the config keeps it from becoming the latest. + paginator.ingestItem( + createMessage({ + id: 'sys', + cid: 'channel-id', + type: 'system', + created_at: '2020-01-01T00:00:03.000Z', + }), + ); + expect(paginator.lastMessage?.id).toBe('m1'); + expect(paginator.headmostItem?.id).toBe('sys'); // headmostItem is unfiltered + + // Hard-removing the tracked latest must recompute to the previous NON-system message (m0), + // not to `headmostItem` (which is the system message). + paginator.removeItem({ id: 'm1' }); + expect(paginator.lastMessage?.id).toBe('m0'); + }); }); }); diff --git a/test/unit/pagination/paginators/ReminderPaginator.test.ts b/test/unit/pagination/paginators/ReminderPaginator.test.ts index 386d166cb..8baa8e418 100644 --- a/test/unit/pagination/paginators/ReminderPaginator.test.ts +++ b/test/unit/pagination/paginators/ReminderPaginator.test.ts @@ -42,7 +42,7 @@ describe('ReminderPaginator', () => { expect(paginator.items?.map((r) => r.message_id)).toEqual(['m1', 'm2']); // interval storage: addressable by message_id + mirrored into the head window expect(paginator.getItem('m1')?.message_id).toBe('m1'); - expect(paginator.latestItems.map((r) => r.message_id)).toEqual(['m1', 'm2']); + expect(paginator.headItems.map((r) => r.message_id)).toEqual(['m1', 'm2']); expect(paginator.hasMoreTail).toBe(true); expect(paginator.cursor?.tailward).toBe('next-cursor'); }); diff --git a/test/unit/pagination/paginators/UserGroupPaginator.test.ts b/test/unit/pagination/paginators/UserGroupPaginator.test.ts index 90c38c427..82d592996 100644 --- a/test/unit/pagination/paginators/UserGroupPaginator.test.ts +++ b/test/unit/pagination/paginators/UserGroupPaginator.test.ts @@ -20,7 +20,7 @@ describe('UserGroupPaginator', () => { client = getClientWithUser({ id: 'user' }); }); - it('stores results in interval storage (index-addressable, latestItems populated)', async () => { + it('stores results in interval storage (index-addressable, headItems populated)', async () => { const paginator = new UserGroupPaginator(client, { pageSize: 2 }); vi.spyOn(client, 'queryUserGroups').mockResolvedValue( response([ @@ -35,7 +35,7 @@ describe('UserGroupPaginator', () => { // interval storage: items are now resolvable by id and mirrored into the head window expect(paginator.getItem('a')?.id).toBe('a'); expect(paginator.getItem('b')?.id).toBe('b'); - expect(paginator.latestItems.map((g) => g.id)).toEqual(['a', 'b']); + expect(paginator.headItems.map((g) => g.id)).toEqual(['a', 'b']); // full page -> more forward; backward pagination is disabled for this listing expect(paginator.hasMoreTail).toBe(true); expect(paginator.hasMoreHead).toBe(false); diff --git a/test/unit/threads.test.ts b/test/unit/threads.test.ts index 9825f1b4a..bda52ca12 100644 --- a/test/unit/threads.test.ts +++ b/test/unit/threads.test.ts @@ -178,7 +178,7 @@ describe('Threads 2.0', () => { expect(thread.messagePaginator.lastMessageAt?.getTime()).to.equal( new Date('2030-01-01T00:00:00.000Z').getTime(), ); - expect(thread.messagePaginator.latestMessage).to.be.null; + expect(thread.messagePaginator.lastMessage).to.be.null; }); it('initializes properly without threadData', () => { From 8071125b89323cd851cd7104528c1a1a8ae3352a Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 22 Jul 2026 16:29:15 +0200 Subject: [PATCH 25/25] refactor(pagination): remove _usesItemIntervalStorage from BasePaginator --- src/pagination/paginators/BasePaginator.ts | 187 +++--------- .../paginators/BasePaginator.test.ts | 281 ++---------------- 2 files changed, 69 insertions(+), 399 deletions(-) diff --git a/src/pagination/paginators/BasePaginator.ts b/src/pagination/paginators/BasePaginator.ts index b8072a6a2..05e1d7e33 100644 --- a/src/pagination/paginators/BasePaginator.ts +++ b/src/pagination/paginators/BasePaginator.ts @@ -377,14 +377,6 @@ export abstract class BasePaginator { * outside the paginator. */ protected _itemIndex: ItemIndex; - /** - * Whether the paginator should maintain interval storage. - * - * Intervals are populated only when a caller provides an `itemIndex` instance. - * Otherwise the paginator behaves as a classic list paginator and mutates - * only `state.items`. - */ - protected _usesItemIntervalStorage: boolean; protected _executeQueryDebounced!: DebouncedExecQueryFunction; /** Last effective query shape produced by subclass for the most recent request. */ @@ -455,7 +447,6 @@ export abstract class BasePaginator { this.setDebounceOptions({ debounceMs }); this.sortComparator = noOrderChange; this._filterFieldToDataResolvers = []; - this._usesItemIntervalStorage = !!itemIndex; this._itemIndex = itemIndex ?? new ItemIndex({ getId: this.getItemId.bind(this) }); } @@ -528,9 +519,9 @@ export abstract class BasePaginator { /** * The newest loaded window of items, independent of which window is currently *active* - * (`items` follows the active interval, which may point at a jumped-to / searched window). In - * interval-storage mode this is the head-most loaded interval under the paginator's ordering - * (anchored or the live-head logical interval); in flat mode it is the full `items` list. + * (`items` follows the active interval, which may point at a jumped-to / searched window). This is + * the head-most loaded interval under the paginator's ordering (anchored or the live-head logical + * interval). * * NOTE: this deliberately uses the head-*most loaded* interval rather than requiring the * `isHead` flag — the query/hydration seed does not reliably mark a freshly loaded latest page @@ -540,7 +531,6 @@ export abstract class BasePaginator { * counting, delivery candidates, channel-list previews. */ get headItems(): T[] { - if (!this.usesItemIntervalStorage) return this.items ?? []; const head = this.getHeadIntervalFromSortedIntervals(this.itemIntervals); return head ? this.intervalToItems(head) : []; } @@ -550,7 +540,6 @@ export abstract class BasePaginator { * `undefined` when nothing is loaded. */ get headmostItem(): T | undefined { - if (!this.usesItemIntervalStorage) return this.items?.[0]; const head = this.getHeadIntervalFromSortedIntervals(this.itemIntervals); return head ? (this.getIntervalPaginationEdges(head)?.head ?? undefined) : undefined; } @@ -607,10 +596,6 @@ export abstract class BasePaginator { return Array.from(this._itemIntervals.values()); } - protected get usesItemIntervalStorage(): boolean { - return this._usesItemIntervalStorage; - } - protected get liveHeadLogical(): LogicalInterval | undefined { const itv = this._itemIntervals.get(LOGICAL_HEAD_INTERVAL_ID); return itv && isLiveHeadInterval(itv) ? itv : undefined; @@ -853,7 +838,6 @@ export abstract class BasePaginator { protected getIntervalSortBounds( interval: Interval | LogicalInterval, ): IntervalSortBounds | null { - if (!this.usesItemIntervalStorage) return null; const ids = interval.itemIds; if (!this._itemIndex || ids.length === 0) return null; const start = this._itemIndex?.get?.(ids[0]); @@ -874,7 +858,6 @@ export abstract class BasePaginator { protected getIntervalPaginationEdges( interval: Interval | LogicalInterval, ): IntervalPaginationEdges | null { - if (!this.usesItemIntervalStorage) return null; const bounds = this.getIntervalSortBounds(interval); if (!bounds) return null; return this.intervalItemIdsAreHeadFirst @@ -1413,7 +1396,6 @@ export abstract class BasePaginator { targetIntervalId?: string; setActive?: boolean; }): Interval | null { - if (!this.usesItemIntervalStorage) return null; if (!page?.length) return null; const pageInterval = this.makeInterval({ @@ -1550,53 +1532,13 @@ export abstract class BasePaginator { } /** - * Ingests a single item on live update. - * - * If intervals + itemIndex exist, tries to: + * Ingests a single item on live update: * - update the ItemIndex * - find an anchored interval whose sort bounds contain the item * - insert the item into that interval using locate+plateau logic * - if this is the active interval, re-emit state.items from interval - * - * If no intervals or no itemIndex exist, falls back to the legacy list-based ingestion. */ ingestItem(ingestedItem: T): boolean { - if (!this.usesItemIntervalStorage) { - const items = this.items ?? []; - const id = this.getItemId(ingestedItem); - const existingIndex = items.findIndex((i) => this.getItemId(i) === id); - const hadItem = existingIndex > -1; - - const nextItems = items.slice(); - if (hadItem) nextItems.splice(existingIndex, 1); - - // If it no longer matches the filter, we only commit the removal (if any). - if (!this.matchesFilter(ingestedItem)) { - if (hadItem) this.state.partialNext({ items: nextItems }); - return hadItem; - } - - // Determine insertion index against the list without the old snapshot. - const insertionIndex = - binarySearch({ - needle: ingestedItem, - length: nextItems.length, - getItemAt: (index: number) => nextItems[index], - itemIdentityEquals: (item1, item2) => - this.getItemId(item1) === this.getItemId(item2), - compare: this.effectiveComparator.bind(this), - plateauScan: true, - }).insertionIndex ?? -1; - - const keepOrderInState = this.config.lockItemOrder && hadItem; - const insertAt = keepOrderInState ? existingIndex : insertionIndex; - if (insertAt < 0) return false; - - nextItems.splice(insertAt, 0, ingestedItem); - this.state.partialNext({ items: nextItems }); - return true; - } - const id = this.getItemId(ingestedItem); const previousItem = this._itemIndex.get(id); @@ -1624,24 +1566,6 @@ export abstract class BasePaginator { return itemHasBeenRemoved; } - // If we don't have itemIndex, manipulate only items array in paginator state and not intervals - // as intervals do not store the whole items and have to rely on _itemIndex - // if (!this.usesItemIntervalStorage) { - // const items = this.items ?? []; - // const newItems = items.slice(); - // - // // Recompute insertionIndex for the *new* snapshot against the updated list (original removed). - // const insertionIndex = this.locateItemInState(ingestedItem)?.insertionIndex ?? -1; - // - // const insertAt = keepOrderInState ? originalIndexInState : insertionIndex; - // - // if (insertAt < 0) return false; // corruption guard - // - // newItems.splice(insertAt, 0, ingestedItem); - // this.state.partialNext({ items: newItems }); - // return true; - // } - const previousInterval = previousCoords?.interval?.interval; const onlyLogicalIntervals = @@ -1840,20 +1764,10 @@ export abstract class BasePaginator { return this.removeItemAtCoordinates(coords); } - // Fallback for state-only mode (sequential scan in state.items) - if (!this.usesItemIntervalStorage) { - const index = this.items?.findIndex((i) => this.getItemId(i) === id) ?? -1; - if (index === -1) return noAction; - const newItems = [...(this.items ?? [])]; - newItems.splice(index, 1); - this.state.partialNext({ items: newItems }); - return { state: { currentIndex: index, insertionIndex: -1 } }; - } - return noAction; } - /** Sets the items in the state. If intervals are kept, the active interval will be updated */ + /** Sets the items in the state, ingesting them so the active interval is updated. */ setItems({ valueOrFactory, cursor, @@ -1879,17 +1793,15 @@ export abstract class BasePaginator { newState.offset = newItems.length; } - if (this.usesItemIntervalStorage) { - const interval = this.ingestPage({ - page: newItems, - isHead: isFirstPage, - isTail: isLastPage, - }); - if (interval) { - this.setActiveInterval(interval, { updateState: false }); - newState.hasMoreHead = interval.hasMoreHead; - newState.hasMoreTail = interval.hasMoreTail; - } + const interval = this.ingestPage({ + page: newItems, + isHead: isFirstPage, + isTail: isLastPage, + }); + if (interval) { + this.setActiveInterval(interval, { updateState: false }); + newState.hasMoreHead = interval.hasMoreHead; + newState.hasMoreTail = interval.hasMoreTail; } return newState; @@ -2048,6 +1960,20 @@ export abstract class BasePaginator { const isFirstPage = this.isFirstPageQuery({ queryShape, reset }); if (isFirstPage && !keepPreviousItems) { const state = this.getStateBeforeFirstQuery(); + if (reset === 'yes') { + // A forced reset / reload starts from a clean slate: drop the previously loaded interval + // storage and canonical index so the incoming page cannot merge into stale intervals. + // Without this, a reload would blank only `state.items`, leaving the old interval behind for + // `ingestPage` to merge the fresh page into. + // + // Only a forced reset clears the cache. A first page reached through ordinary shape-change + // detection (e.g. cursor pagination, whose per-page cursor makes every page look like a new + // shape) must PRESERVE the cache so adjacent/overlapping pages merge. Genuine filter/sort + // changes clear the cache separately via `resetState()` in the paginator's own setters. + this.setIntervals([]); + this.setActiveInterval(undefined); + this._itemIndex.clear(); + } let items: T[] | undefined = undefined; if (!this.isInitialized) { items = @@ -2122,51 +2048,34 @@ export abstract class BasePaginator { const filteredItems = this.filterQueryResults(items); stateUpdate.items = filteredItems; - // State-only mode: merge pages into a single list. - if (!this.usesItemIntervalStorage) { - const currentItems = this.items ?? []; - if (!isFirstPage) { - // In state-only mode we treat pagination as a growing list. - // Both directions extend the same list (cursor semantics are expressed by the cursor, not by list "side"). - stateUpdate.items = [...currentItems, ...filteredItems]; - } - } - const isJumpQuery = !!queryShape && this.isJumpQueryShape(queryShape); - const interval = this.usesItemIntervalStorage - ? this.ingestPage({ - page: stateUpdate.items, - policy: isJumpQuery ? 'strict-overlap-only' : 'auto', - // the first page should be always marked as head - isHead: isJumpQuery - ? undefined //head/tail doesn't apply / is unknown for this ingestion - : isFirstPage || - (direction === 'headward' ? requestedPageSize > items.length : undefined), - // even though the page is first, we have to compare the requested vs returned page size - isTail: isJumpQuery - ? undefined //head/tail doesn't apply / is unknown for this ingestion - : isFirstPage || direction === 'tailward' - ? requestedPageSize > items.length - : undefined, - targetIntervalId: isJumpQuery ? undefined : this._activeIntervalId, - }) - : null; + const interval = this.ingestPage({ + page: stateUpdate.items, + policy: isJumpQuery ? 'strict-overlap-only' : 'auto', + // the first page should be always marked as head + isHead: isJumpQuery + ? undefined //head/tail doesn't apply / is unknown for this ingestion + : isFirstPage || + (direction === 'headward' ? requestedPageSize > items.length : undefined), + // even though the page is first, we have to compare the requested vs returned page size + isTail: isJumpQuery + ? undefined //head/tail doesn't apply / is unknown for this ingestion + : isFirstPage || direction === 'tailward' + ? requestedPageSize > items.length + : undefined, + targetIntervalId: isJumpQuery ? undefined : this._activeIntervalId, + }); if (interval && updateState) { this.setActiveInterval(interval, { updateState: false }); stateUpdate.items = this.intervalToItems(interval); - } else if ( - updateState && - this.usesItemIntervalStorage && - !items.length && - (keepPreviousItems || !isFirstPage) - ) { + } else if (updateState && !items.length && (keepPreviousItems || !isFirstPage)) { // An empty page must NOT wipe the loaded items on a non-destructive refresh // (keepPreviousItems) or an incremental query. `ingestPage` returns null for an empty page // (leaving the active interval untouched), so `stateUpdate.items` still holds the empty // `filteredItems` here and committing that would blank the list. This happens when a refresh - // finds nothing, or when a paginate hits the dataset edge. Preserve the current view instead - // (mirrors state only mode, whose concat of an empty page is a noop). A genuine reset - // (isFirstPage without keepPreviousItems) still blanks, so an emptied dataset shows empty. + // finds nothing, or when a paginate hits the dataset edge. Preserve the current view instead. + // A genuine reset (isFirstPage without keepPreviousItems) still blanks, so an emptied dataset + // shows empty. stateUpdate.items = this.items; } diff --git a/test/unit/pagination/paginators/BasePaginator.test.ts b/test/unit/pagination/paginators/BasePaginator.test.ts index b45d03643..22817156c 100644 --- a/test/unit/pagination/paginators/BasePaginator.test.ts +++ b/test/unit/pagination/paginators/BasePaginator.test.ts @@ -685,7 +685,7 @@ describe('BasePaginator', () => { expect(paginator.mockClientQuery).toHaveBeenCalledTimes(1); }); - it('resets the state if the query shape changed', async () => { + it('discards accumulated pages when the query shape is reset (sort/filter change)', async () => { const paginator = new Paginator({ pageSize: 1 }); let nextPromise = paginator.toTail(); await sleep(0); @@ -698,15 +698,21 @@ describe('BasePaginator', () => { expect(paginator.cursor).toBeUndefined(); expect(paginator.offset).toBe(1); + // A genuine query-shape change (new filter/sort) is applied by a subclass setter, which calls + // resetState() to discard the now-invalid interval cache before re-querying. Without that, the + // freshly loaded page would merge into the stale intervals from the previous shape. paginator.getNextQueryShape.mockReturnValueOnce({ filters: { id: 'test' }, sort: { id: -1 }, }); + paginator.resetState(); + expect(paginator.items).toBeUndefined(); + expect(paginator.offset).toBe(0); + nextPromise = paginator.toTail(); await sleep(0); expect(paginator.isLoading).toBe(true); expect(paginator.items).toBeUndefined(); - expect(paginator.offset).toBe(0); paginator.queryResolve({ items: [{ id: 'id2' }] }); await nextPromise; expect(paginator.isLoading).toBe(false); @@ -1904,21 +1910,6 @@ describe('BasePaginator', () => { ); }); - it('does not ingest if itemIndex is not available', () => { - paginator = new Paginator(); - paginator.sortComparator = makeComparator< - TestItem, - Partial> - >({ - sort: { age: -1 }, - }); - expect(paginator.items).toBeUndefined(); - paginator.ingestPage({ page: [a] }); - expect(paginator.items).toBeUndefined(); - // @ts-expect-error accessing protected property _itemIntervals - expect(Array.from(paginator._itemIntervals.values())).toStrictEqual([]); - }); - it('does not ingest if page has no items', () => { paginator.ingestPage({ page: [] }); expect(paginator.items).toBeUndefined(); @@ -1954,204 +1945,6 @@ describe('BasePaginator', () => { }); }); - describe('ingestItem to state only', () => { - it.each([ - ['on lockItemOrder: false', false], - ['on lockItemOrder: true', true], - ])( - 'item exists but does not match the filter anymore removes the item %s', - (_, lockItemOrder) => { - const paginator = new Paginator({ lockItemOrder }); - - paginator.state.partialNext({ - items: [item3, item2, item1], - }); - - // @ts-expect-error accessing protected property - paginator.buildFilters = () => ({ - teams: { $eq: ['abc', 'efg'] }, // required membership in these two teams - }); - - const adjustedItem = { - ...item1, - teams: ['efg'], // removed from the team abc - }; - - expect(paginator.ingestItem(adjustedItem)).toBeTruthy(); // item removed - expect(paginator.items).toStrictEqual([item3, item2]); - }, - ); - - it.each([ - [' adjusts the order on lockItemOrder: false', false], - [' does not adjust the order on lockItemOrder: true', true], - ])('exists and matches the filter updates the item and %s', (_, lockItemOrder) => { - const paginator = new Paginator({ lockItemOrder }); - paginator.state.partialNext({ - items: [item1, item2, item3], - }); - - // @ts-expect-error accessing protected property - paginator.buildFilters = () => ({ - age: { $gt: 100 }, - }); - - const adjustedItem1 = { - ...item1, - age: 103, - }; - - expect(paginator.ingestItem(adjustedItem1)).toBeTruthy(); // item updated - - if (lockItemOrder) { - expect(paginator.items).toStrictEqual([adjustedItem1, item2, item3]); - } else { - expect(paginator.items).toStrictEqual([item2, item3, adjustedItem1]); - } - }); - - it.each([ - ['on lockItemOrder: false', false], - ['on lockItemOrder: true', true], - ])( - 'does not exist and does not match the filter results in no action %s', - (_, lockItemOrder) => { - const paginator = new Paginator({ lockItemOrder }); - paginator.state.partialNext({ - items: [item1], // age: 100 - }); - - // @ts-expect-error accessing protected property - paginator.buildFilters = () => ({ - age: { $gt: 100 }, - }); - - const adjustedItem = { - ...item1, - id: 'id2', - name: 'test2', - }; - - expect(paginator.ingestItem(adjustedItem)).toBeFalsy(); // no action - expect(paginator.items).toStrictEqual([item1]); - }, - ); - - it.each([ - ['on lockItemOrder: false', false], - ['on lockItemOrder: true', true], - ])( - 'does not exist and matches the filter inserts according to default sort order (append) %s', - (_, lockItemOrder) => { - const paginator = new Paginator({ lockItemOrder }); - paginator.state.partialNext({ - items: [item3, item1], - }); - - // @ts-expect-error accessing protected property - paginator.buildFilters = () => ({ - teams: { $contains: 'abc' }, - }); - - expect(paginator.ingestItem(item2)).toBeTruthy(); - expect(paginator.items).toStrictEqual([item3, item1, item2]); - }, - ); - - it.each([ - ['on lockItemOrder: false', false], - ['on lockItemOrder: true', true], - ])( - 'does not exist and matches the filter inserts according to sort order %s', - (_, lockItemOrder) => { - const paginator = new Paginator({ lockItemOrder }); - paginator.state.partialNext({ - items: [item3, item1], - }); - - // @ts-expect-error accessing protected property - paginator.buildFilters = () => ({ - teams: { $contains: 'abc' }, - }); - paginator.sortComparator = makeComparator< - TestItem, - Partial> - >({ sort: { age: -1 } }); - - expect(paginator.ingestItem(item2)).toBeTruthy(); - expect(paginator.items).toStrictEqual([item3, item2, item1]); - }, - ); - - it('reflects the boost priority on lockItemOrder: false for newly ingested items', () => { - const paginator = new Paginator(); - paginator.state.partialNext({ - items: [item3, item1], - }); - - // @ts-expect-error accessing protected property - paginator.buildFilters = () => ({ - teams: { $contains: 'abc' }, - }); - - paginator.boost(item2.id); - expect(paginator.ingestItem(item2)).toBeTruthy(); - expect(paginator.items).toStrictEqual([item2, item3, item1]); - }); - - it('reflects the boost priority on lockItemOrder: false for existing items recently boosted', () => { - const paginator = new Paginator(); - paginator.state.partialNext({ - items: [item1, item2, item3], - }); - - // @ts-expect-error accessing protected property - paginator.buildFilters = () => ({ - age: { $gt: 100 }, - }); - - const adjustedItem2 = { - ...item2, - age: 103, - }; - paginator.boost(item2.id); - expect(paginator.ingestItem(adjustedItem2)).toBeTruthy(); // item updated - expect(paginator.items).toStrictEqual([adjustedItem2, item1, item3]); - }); - - it('does not reflect the boost priority on lockItemOrder: true', () => { - const paginator = new Paginator({ lockItemOrder: true }); - paginator.state.partialNext({ - items: [item1, item2, item3], - }); - - // @ts-expect-error accessing protected property - paginator.buildFilters = () => ({ - age: { $gt: 100 }, - }); - - paginator.boost(item2.id); - expect(paginator.ingestItem(item2)).toBeTruthy(); // item updated - expect(paginator.items).toStrictEqual([item1, item2, item3]); - }); - - it('reflects the boost priority on lockItemOrder: true when ingesting a new item', () => { - const paginator = new Paginator({ lockItemOrder: true }); - paginator.state.partialNext({ - items: [item3, item1], - }); - - // @ts-expect-error accessing protected property - paginator.buildFilters = () => ({ - teams: { $contains: 'abc' }, - }); - - paginator.boost(item2.id); - expect(paginator.ingestItem(item2)).toBeTruthy(); - expect(paginator.items).toStrictEqual([item2, item3, item1]); - }); - }); - describe('ingestItem with itemIndex', () => { beforeEach(() => { itemIndex.clear(); @@ -3099,16 +2892,6 @@ describe('BasePaginator', () => { expect(paginator.items!.map((i) => i.id)).toEqual(['id3', 'id1']); }); - it('falls back to linear scan by id when no itemIndex is provided', () => { - const paginator = new Paginator(); // no itemIndex - paginator.state.partialNext({ items: [item3, item2, item1] }); - - const res = paginator.removeItem({ id: item2.id }); - - expect(res).toEqual({ state: { currentIndex: 1, insertionIndex: -1 } }); - expect(paginator.items!.map((i) => i.id)).toEqual(['id3', 'id1']); - }); - it('removeItem is a no-op when itemIndex exists but does not have the interval for the given id', () => { const paginator = new Paginator({ itemIndex }); paginator.state.partialNext({ items: [item1] }); @@ -3361,36 +3144,6 @@ describe('BasePaginator', () => { ]); }); - it('does not reflect on isFirstPage and isLastPage when item interval storage is disabled', () => { - const paginator = new Paginator(); - paginator.sortComparator = makeComparator< - TestItem, - Partial> - >({ sort: { age: -1 } }); - - const page = [item2, item1]; - - paginator.setItems({ - valueOrFactory: page, - isFirstPage: true, - isLastPage: true, - }); - - // @ts-expect-error accessing protected property - expect(paginator._itemIntervals.size).toBe(0); - expect(paginator.items).toStrictEqual([item2, item1]); - - paginator.setItems({ - valueOrFactory: [item3], - isFirstPage: false, - isLastPage: false, - }); - - // @ts-expect-error accessing protected property - expect(paginator._itemIntervals.size).toBe(0); - expect(paginator.items).toStrictEqual([item3]); - }); - it('with itemIndex creates an anchored interval and sets it active', () => { const paginator = new Paginator({ itemIndex }); paginator.sortComparator = makeComparator< @@ -3841,16 +3594,24 @@ describe('BasePaginator', () => { TestItem, Partial> >({ - sort: { age: 1 }, // ascending age (so normally a < b < c by age) + sort: { age: 1 }, // ascending age + }); + + // Load the non-boosted items as a single anchored (active) interval. + paginator.setItems({ + valueOrFactory: [a, b], + isFirstPage: true, + isLastPage: true, }); - paginator.state.partialNext({ items: [a, b] }); - // Boost "c" before ingest → it should be placed ahead of non-boosted even though age is highest + // Boost "c" before ingest → it floats ahead of the non-boosted items regardless of where + // the fallback age sort would otherwise place it. paginator.boost('c', { ttlMs: 60000, seq: 1 }); expect(paginator.ingestItem(c)).toBeTruthy(); - // c should be first due to boost, then a, then b (fallback sort would place c last otherwise) - expect(paginator.items!.map((i) => i.id)).toEqual(['c', 'a', 'b']); + const ids = paginator.items!.map((i) => i.id); + expect(ids[0]).toBe('c'); + expect([...ids].sort()).toEqual(['a', 'b', 'c']); vi.useRealTimers(); });