Skip to content

feat(llc): Implement local unread counts#2804

Open
renefloor wants to merge 1 commit into
masterfrom
feat/local-unread-count
Open

feat(llc): Implement local unread counts#2804
renefloor wants to merge 1 commit into
masterfrom
feat/local-unread-count

Conversation

@renefloor

@renefloor renefloor commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Submit a pull request

Linear: FLU-587

CLA

  • I have signed the Stream CLA (required).
  • The code changes follow best practices
  • Code changes are tested (add some information if not applicable)

Description of the pull request

This adds an option for local only unread counts, especially interesting for things like livestreams with many updates.

Summary by CodeRabbit

  • New Features
    • Added optional local unread-count tracking for channels without read events (disabled by default via isLocalUnreadCountEnabled).
    • When enabled, unread counts update locally on incoming messages and decrement only on hard deletions.
    • markRead / markUnread / markUnreadByTimestamp update local unread state without network requests.
    • Syncing channel state from remote/offline storage now preserves locally tracked unread data.
  • Documentation
    • Updated the changelog with behavior details for local unread tracking.
  • Tests
    • Added coverage for local unread-count behavior and merge rules.

@renefloor renefloor changed the title Implement local unread counts feat(ui): Implement local unread counts Jul 10, 2026
@renefloor renefloor changed the title feat(ui): Implement local unread counts feat(llc): Implement local unread counts Jul 10, 2026
@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c9c80023-d806-41ab-9c1f-40c2d43ffdcb

📥 Commits

Reviewing files that changed from the base of the PR and between 82e91e4 and a495169.

📒 Files selected for processing (8)
  • docs/docs_screenshots/test/src/mocks.dart
  • packages/stream_chat/CHANGELOG.md
  • packages/stream_chat/lib/src/client/channel.dart
  • packages/stream_chat/lib/src/client/client.dart
  • packages/stream_chat/lib/src/core/util/message_rules.dart
  • packages/stream_chat/test/src/client/channel_test.dart
  • packages/stream_chat/test/src/core/util/message_rules_test.dart
  • packages/stream_chat/test/src/mocks.dart
✅ Files skipped from review due to trivial changes (1)
  • packages/stream_chat/CHANGELOG.md
🚧 Files skipped from review as they are similar to previous changes (7)
  • packages/stream_chat/test/src/mocks.dart
  • docs/docs_screenshots/test/src/mocks.dart
  • packages/stream_chat/test/src/core/util/message_rules_test.dart
  • packages/stream_chat/lib/src/client/client.dart
  • packages/stream_chat/lib/src/core/util/message_rules.dart
  • packages/stream_chat/lib/src/client/channel.dart
  • packages/stream_chat/test/src/client/channel_test.dart

📝 Walkthrough

Walkthrough

Adds an opt-in local unread-count mode for channels without read events, including local read mutations, message-based count updates, remote-state preservation, tests, mocks, and changelog documentation.

Changes

Local unread count tracking

Layer / File(s) Summary
Unread count configuration and eligibility
packages/stream_chat/lib/src/client/client.dart, packages/stream_chat/lib/src/client/channel.dart, packages/stream_chat/lib/src/core/util/message_rules.dart, packages/stream_chat/test/src/core/util/message_rules_test.dart
Adds isLocalUnreadCountEnabled, derives usesLocalUnreadCount, and allows unread counting without read receipts when local tracking is enabled.
Local channel mutations and state merging
packages/stream_chat/lib/src/client/channel.dart, packages/stream_chat/lib/src/client/client.dart
Adds local mark-read/unread operations, local unread recomputation, hard-delete decrements, and remote-state merging that preserves local unread fields.
Behavior validation and supporting wiring
packages/stream_chat/test/src/client/channel_test.dart, packages/stream_chat/test/src/mocks.dart, docs/docs_screenshots/test/src/mocks.dart, packages/stream_chat/CHANGELOG.md
Tests local unread behavior and updates test mocks and changelog documentation for the new flag.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant MessageEvent
  participant Channel
  participant MessageRules
  participant ChannelClientState
  MessageEvent->>Channel: Receive message.new or message.deleted
  Channel->>MessageRules: Check unread eligibility
  MessageRules-->>Channel: Return eligibility
  Channel->>ChannelClientState: Update local unread count
  Channel->>ChannelClientState: Apply local read boundary
  ChannelClientState-->>Channel: Return recomputed unread state
Loading

Suggested reviewers: xsahil03x, VelikovPetar

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main change: adding local unread count support.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/local-unread-count

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@renefloor renefloor force-pushed the feat/local-unread-count branch from 82e91e4 to a495169 Compare July 10, 2026 08:46

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/stream_chat/test/src/client/channel_test.dart (1)

9810-10089: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a positive-path test for Channel.markUnread local recompute.

The new group thoroughly covers markRead and markUnreadByTimestamp local recompute, plus markUnread's throw-when-unknown-message case, but there's no test exercising a successful markUnread(messageId) call and asserting the resulting unreadCount — the anchor/microsecond-offset arithmetic in Channel.markUnread (subtracting 1 microsecond from the anchor's createdAt) is currently only covered indirectly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/stream_chat/test/src/client/channel_test.dart` around lines 9810 -
10089, The local unread-count tests lack a successful-path case for
Channel.markUnread. Add a test in the “Local unread count” group that creates
locally known messages with distinct timestamps, calls markUnread with a valid
message ID, asserts unreadCount reflects only messages after the anchor
(including the one-microsecond offset behavior), and verifies no network request
via client.markChannelUnread.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/stream_chat/lib/src/client/channel.dart`:
- Around line 3615-3637: The markReadLocally method does not reconcile pending
delivery receipts after updating read state. Add
_client.channelDeliveryReporter.reconcileDelivery([_channel]) to markReadLocally
after updateRead, matching the delivery reconciliation behavior used by related
mark-read flows.

---

Nitpick comments:
In `@packages/stream_chat/test/src/client/channel_test.dart`:
- Around line 9810-10089: The local unread-count tests lack a successful-path
case for Channel.markUnread. Add a test in the “Local unread count” group that
creates locally known messages with distinct timestamps, calls markUnread with a
valid message ID, asserts unreadCount reflects only messages after the anchor
(including the one-microsecond offset behavior), and verifies no network request
via client.markChannelUnread.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 030f7148-3f52-4392-aa77-629db23c3680

📥 Commits

Reviewing files that changed from the base of the PR and between 484ada6 and 82e91e4.

📒 Files selected for processing (8)
  • docs/docs_screenshots/test/src/mocks.dart
  • packages/stream_chat/CHANGELOG.md
  • packages/stream_chat/lib/src/client/channel.dart
  • packages/stream_chat/lib/src/client/client.dart
  • packages/stream_chat/lib/src/core/util/message_rules.dart
  • packages/stream_chat/test/src/client/channel_test.dart
  • packages/stream_chat/test/src/core/util/message_rules_test.dart
  • packages/stream_chat/test/src/mocks.dart

Comment on lines +3615 to +3637
/// Marks the channel as read locally, without making a network request.
///
/// Used for channels that track unread counts locally (see
/// [Channel.usesLocalUnreadCount]), since the server rejects the mark-read
/// endpoint for channels that have read events disabled.
void markReadLocally({String? messageId}) {
final currentUser = _client.state.currentUser;
if (currentUser == null) return;

final now = DateTime.now();
final lastReadMessageId = messageId ?? messages.lastOrNull?.id;

final existingUserRead = currentUserRead;
updateRead([
Read(
user: currentUser,
lastRead: now,
lastReadMessageId: lastReadMessageId,
lastDeliveredAt: existingUserRead?.lastDeliveredAt,
lastDeliveredMessageId: existingUserRead?.lastDeliveredMessageId,
),
]);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate relevant symbols and surrounding code
rg -n "_listenReadEvents|reconcileDelivery|markReadLocally|markRead\\(" packages/stream_chat/lib/src/client/channel.dart packages/stream_chat/lib/src -S

# Show the relevant slice around markReadLocally
sed -n '3570,3665p' packages/stream_chat/lib/src/client/channel.dart

# Show the read event listener slice
sed -n '3000,3095p' packages/stream_chat/lib/src/client/channel.dart

Repository: GetStream/stream-chat-flutter

Length of output: 8499


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "canUseDeliveryReceipts|canUseReadReceipts|delivery receipts|read receipts" packages/stream_chat/lib -S

Repository: GetStream/stream-chat-flutter

Length of output: 3601


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "void updateRead|updateRead\\(|currentUserRead|channelDeliveryReporter|reconcileDelivery" packages/stream_chat/lib/src -S

Repository: GetStream/stream-chat-flutter

Length of output: 4513


🌐 Web query:

stream_chat ChannelDeliveryReporter reconcileDelivery markReadLocally read receipts delivery receipts

💡 Result:

In the Stream Chat SDK (specifically the Dart/Flutter implementation), ChannelDeliveryReporter is a utility class responsible for managing and throttling delivery receipt reporting to the server [1][2][3]. Core Components and Functionality: ChannelDeliveryReporter: This class collects channels requiring delivery acknowledgments and reports them to the server efficiently [1][2]. It includes a throttle mechanism to prevent excessive API calls [2][3]. - reconcileDelivery(Iterable channels): Reconciles the delivery reporting state for a set of channels with their current internal state [1][2]. - submitForDelivery(Iterable channels): Submits channels to the reporter queue for delivery receipt processing [1][2]. - cancelDelivery(Iterable channels): Cancels pending delivery reports for specific channels [1][2]. Delivery Receipts vs. Read Receipts: - Delivery Receipts track when a message has been successfully delivered to a recipient's device [4][5]. This is confirmed via the message.delivered WebSocket event [4]. It is disabled by default and may require specific configuration or support contact [4][5]. - Read Receipts track when a user has actively viewed a message or marked a channel as read [4][5]. This is confirmed via the message.read WebSocket event [4]. Implementation Notes: - The SDK handles delivery confirmation automatically, including throttling and duplicate prevention [4][5]. - Developers can access the full read and delivery state (e.g., lastDeliveredAt, lastDeliveredMessageId) via the channel.state.read object, which provides a list of members and their respective read/delivery statuses [6]. - While markReadLocally is often used in local state management to update UI immediately before the server confirms the action, the SDK provides explicit methods like markChannelRead, markAllRead, and markChannelsDelivered to sync state with the Stream server [7]. The MarkChannelsDelivered typedef is the callback function used to transmit these delivery acknowledgments to the backend [8].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1848,1875p' packages/stream_chat/lib/src/client/channel.dart
printf '\n----\n'
sed -n '3288,3335p' packages/stream_chat/lib/src/client/channel.dart
printf '\n----\n'
sed -n '3398,3492p' packages/stream_chat/lib/src/client/channel.dart
printf '\n----\n'
sed -n '4698,4768p' packages/stream_chat/lib/src/client/channel.dart
printf '\n----\n'
sed -n '96,145p' packages/stream_chat/lib/src/client/channel_delivery_reporter.dart

Repository: GetStream/stream-chat-flutter

Length of output: 10239


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "markReadLocally\\(" packages/stream_chat/lib -S

Repository: GetStream/stream-chat-flutter

Length of output: 373


markReadLocally should reconcile delivery receipts too.
It only updates read state; for channels with delivery receipts enabled, skipping _client.channelDeliveryReporter.reconcileDelivery([_channel]) leaves pending delivery state stale after a local mark-read.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/stream_chat/lib/src/client/channel.dart` around lines 3615 - 3637,
The markReadLocally method does not reconcile pending delivery receipts after
updating read state. Add
_client.channelDeliveryReporter.reconcileDelivery([_channel]) to markReadLocally
after updateRead, matching the delivery reconciliation behavior used by related
mark-read flows.

@codecov

codecov Bot commented Jul 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.00000% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 71.04%. Comparing base (484ada6) to head (a495169).

Files with missing lines Patch % Lines
packages/stream_chat/lib/src/client/channel.dart 93.75% 3 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #2804      +/-   ##
==========================================
+ Coverage   71.00%   71.04%   +0.04%     
==========================================
  Files         429      429              
  Lines       26891    26938      +47     
==========================================
+ Hits        19093    19138      +45     
- Misses       7798     7800       +2     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

// keep their slot. Only applies to channels that track unread counts
// locally (see [Channel.usesLocalUnreadCount]) — server-driven
// channels get corrected counts from server read events instead.
if (hardDelete && _channel.usesLocalUnreadCount && MessageRules.canCountAsUnread(message, _channel)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just curious: Don't soft-deletes also decrement the unread count?

_checkInitialized();

if (usesLocalUnreadCount) {
state!.markUnreadLocally(lastRead: timestamp);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the markUnread(String messageId) method, we call markUnreadLocally with a -1ms of the message timestamp -> but here we call the markUnreadLocally with the exact timestamp. I am curious whether we might be missing some edge-case here, because this method will usually be called with the exact createdAt timestamp of a specific message. So if we call:

final message = targetMessage;

channel.markUnread(message.id);
channel.markUnreadByTimestamp(message.createdAt);

Would result in calling markUnreadLocally with a different timestamp. Probably not a big deal, but I wanted to flag it nonetheless!

updateRead([
Read(
user: currentUser,
lastRead: now,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: Does the lastRead mark the moment the channel was read, or the timestamp of the last read message? (I think we should align this with how the server populates this field)


final localRead = currentUserRead;
final remoteReads = remoteState.read;
if (localRead == null || remoteReads == null) return remoteState;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: Will we always get an initial Read object in the response for a channel which has read/delivery receipts disabled? I am worried that if we do not get an initial object, we won't have a baseline to update on message.new/message.deleted events.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants