Skip to content

feat(ui, core, llc, localization): unread banners - #2871

Open
renefloor wants to merge 7 commits into
masterfrom
worktree-unread-banners
Open

feat(ui, core, llc, localization): unread banners#2871
renefloor wants to merge 7 commits into
masterfrom
worktree-unread-banners

Conversation

@renefloor

@renefloor renefloor commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Submit a pull request

Linear:
fixes FLU-672 (main issue)
fixes FLU-640
fixes FLU-648
fixes FLU-649
fixes FLU-650

Github Issue: #

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

The requirements changed again while this was being build, so the linear tickets are not right.

Requirements:

  • When opening a channel with unread messages it shows the unread banner. That banner will stay there until you close the channel page and the message count increases when you get new messages.
  • The scroll-to-bottom button only gets a count when message are received while you are scrolled up. The count goes away when you're at the bottom
  • The pill at the top shows only when you open a channel with unreads and disappears after you get past the first message.
  • Channel should open at the newest message, but that's kind of breaking so I've made it an 'opt-in' feature of the StreamChannel object.
  • No separate 'new messages' banner when messages arrive while having the chat open.

Screenshots / Videos

Simulator.Screen.Recording.-.iPhone.17.Pro.Max.-.2026-08-06.at.12.02.06.mov
Simulator.Screen.Recording.-.iPhone.17.Pro.Max.-.2026-08-06.at.12.00.20.mov
Simulator.Screen.Recording.-.iPhone.17.Pro.Max.-.2026-08-06.at.12.00.45.mov

Summary by CodeRabbit

  • New Features

    • Improved unread-message tracking, including manual mark-unread state and current-user read events.
    • Added configurable channel opening behavior: start at the first unread message or latest message.
    • Added unread dividers and scroll-to-bottom badges with dynamically updated counts.
    • Added configurable automatic mark-as-read rules and localized singular/plural unread labels.
  • Bug Fixes

    • Improved unread navigation, viewport detection, message counting, and badge dismissal behavior.
    • Prevented messages from other users or thread replies from incorrectly affecting unread counts.

renefloor and others added 4 commits August 6, 2026 12:35
Tracks whether the current user has an active manual mark-unread on
the channel that hasn't been read past yet, mirroring the iOS SDK's
ReadStateHandler.isMarkedAsUnread.

Set by markUnreadLocally and by a notification.mark_unread event for
the current user; cleared by markReadLocally and by a message.read
event for the current user.

Intended for UI-layer gating that shouldn't immediately undo a manual
mark-unread — used by stream_chat_flutter's tightened mark-read gating
(FLU-640).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Gates the existing auto-scroll-to-first-unread positioning behind an
opt-out flag on StreamChannel/StreamChannel.value, defaulting to true
so existing integrations keep today's behavior unchanged. Set to
false to always open a channel at the latest message instead, and
let the message list surface pre-existing unread via its divider and
jump-to-unread pill rather than by scrolling there automatically.

Updates the sample app's channel route to demonstrate the flag.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
/649/650/640)

Unread messages divider: anchored to the pre-existing read/unread
boundary captured when the channel opens. The anchor is frozen for
the whole session — it never moves or disappears, regardless of
scrolling or reads — but its displayed count keeps counting up as
further messages arrive during the session (mirroring WhatsApp)
instead of staying fixed at the open-time total.

Jump-to-unread pill (UnreadIndicatorButton): shows the frozen
open-time count, gated on that boundary sitting above the viewport.
Visible as soon as the count is known from the channel's Read state,
even before the boundary message itself has loaded — tapping it
before then falls back to loadChannelAtMessage via the boundary's
lastReadMessageId. Dismisses permanently for the session on tap, the
dismiss button, or scrolling past it; the button itself is now purely
presentational, taking a required unreadCount instead of subscribing
to read state internally.

Scroll-to-bottom badge: counts only messages that arrive while
scrolled away from the bottom (never seeded from the channel's unread
count, unlike the divider above), and always resets to 0 once the
user reaches the bottom.

Mark-read gating (FLU-640): tightened to mirror iOS's
shouldMarkChannelRead — besides isUpToDate and unreadCount > 0, now
also requires the bottom to have been seen (now, or earlier then
scrolled away), the pre-existing boundary (if any) to have been seen
or scrolled past, and no active manual mark-unread
(Channel.isMarkedAsUnread). That last check can't gate on the flag
directly and permanently: it only clears via a successful mark-read,
which is the very thing it would be gating, so it would deadlock the
channel unread forever the moment it's set. Instead it latches once
the viewport genuinely diverges from a snapshot taken when the
mark-unread was first observed — captured eagerly on a live
transition, or on the first laid-out frame as a fallback for a
channel that simply mounts already marked unread.

Adds StreamMessageListViewConfiguration.shouldMarkRead to override
this gating entirely, and Translations.unreadMessagesSeparatorLabel
(added rather than changing the existing unreadMessagesSeparatorText,
to avoid breaking existing overrides) so the default separator can
show a count.

Also defaults MockChannelState.isMarkedAsUnread to false, since
_handleItemPositionsChanged now reads it on every scroll tick and
existing test files that construct the mock without stubbing it
would otherwise crash.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds the new count-aware label (Translations.unreadMessagesSeparatorLabel,
introduced in stream_chat_flutter) across all 11 supported locales,
plus the add_new_lang.dart example template and test coverage.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 6, 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 Plus

Run ID: 4a079e36-c56f-4f14-b08b-fd3011d42998

📥 Commits

Reviewing files that changed from the base of the PR and between 5c3e35e and 27e1a51.

📒 Files selected for processing (5)
  • packages/stream_chat_flutter/lib/src/message_list_view/mark_read_details.dart
  • packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart
  • packages/stream_chat_flutter/lib/src/message_list_view/stream_message_list_view_configuration.dart
  • packages/stream_chat_flutter/test/src/message_list_view/mark_read_test.dart
  • packages/stream_chat_flutter_core/lib/src/stream_channel.dart
🚧 Files skipped from review as they are similar to previous changes (5)
  • packages/stream_chat_flutter/lib/src/message_list_view/stream_message_list_view_configuration.dart
  • packages/stream_chat_flutter_core/lib/src/stream_channel.dart
  • packages/stream_chat_flutter/test/src/message_list_view/mark_read_test.dart
  • packages/stream_chat_flutter/lib/src/message_list_view/mark_read_details.dart
  • packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart

📝 Walkthrough

Walkthrough

The PR adds manual unread state tracking, count-based unread indicators and separators, configurable automatic mark-read gating, revised unread divider and scroll-to-bottom behavior, and an openAtFirstUnread channel option with localization support.

Changes

Unread state and message list flow

Layer / File(s) Summary
Channel manual unread state
packages/stream_chat/lib/src/client/channel.dart, packages/stream_chat/test/src/client/channel_test.dart
ChannelClientState.isMarkedAsUnread tracks current-user manual unread events and local mark-unread operations. Read events and local mark-read operations clear the state.
Unread baseline and mark-read flow
packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart, packages/stream_chat_flutter/lib/src/message_list_view/mark_read_details.dart, packages/stream_chat_flutter/lib/src/message_list_view/stream_message_list_view_configuration.dart, packages/stream_chat_flutter/test/src/message_list_view/mark_read_test.dart, packages/stream_chat_flutter/test/src/message_list_view/unread_divider_test.dart
The message list tracks unread anchors, accumulated counts, viewport visibility, and manual unread sessions. Automatic mark-read now checks these states and optional predicates.
Unread controls and localization
packages/stream_chat_flutter/lib/src/message_list_view/unread_indicator_button.dart, packages/stream_chat_flutter/lib/src/message_list_view/unread_messages_separator.dart, packages/stream_chat_flutter/lib/src/localization/translations.dart, packages/stream_chat_localizations/lib/src/*, packages/stream_chat_flutter/test/src/localization/*, packages/stream_chat_localizations/test/*
Unread controls receive explicit counts, and unread separators use singular or plural localized labels across supported locales.
Initial channel positioning
packages/stream_chat_flutter_core/lib/src/stream_channel.dart, packages/stream_chat_flutter/lib/src/message_list_view/mlv_utils.dart, sample_app/lib/routes/app_routes.dart
StreamChannel.openAtFirstUnread defaults to true. When disabled, channel initialization opens at the latest messages.

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

Sequence Diagram(s)

sequenceDiagram
  participant MessageStream
  participant StreamMessageListView
  participant ChannelClientState
  MessageStream->>StreamMessageListView: Deliver messages and read-state events
  StreamMessageListView->>StreamMessageListView: Track unread anchor and viewport state
  StreamMessageListView->>ChannelClientState: Evaluate mark-read conditions
  ChannelClientState-->>StreamMessageListView: Apply current-user read state
Loading
🚥 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 summarizes the primary unread banner and indicator changes across the UI, core, client, and localization layers.
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 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch worktree-unread-banners

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 changed the title Worktree unread banners feat(ui, core, llc, localizations): unread banners Aug 6, 2026
@renefloor renefloor changed the title feat(ui, core, llc, localizations): unread banners feat(ui, core, llc, localization): unread banners Aug 6, 2026
@renefloor
renefloor marked this pull request as ready for review August 6, 2026 14:09

@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: 5

🧹 Nitpick comments (3)
packages/stream_chat_flutter/test/src/message_list_view/mark_read_test.dart (1)

328-370: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert on the details passed to shouldMarkRead.

Both override tests return a constant and ignore details. They prove the override is consulted but not that it receives correct inputs. In the second test the default gating blocks the read, so details.hasSeenFirstUnreadMessage must be false and details.unreadCount must be 5. Capturing and asserting those fields protects the StreamMarkReadDetails contract.

Consider also adding a case where the channel has an active manual mark-unread and a new message arrives. That path is currently uncovered and is where the viewport-divergence signal is weakest.

💚 Sketch
late StreamMarkReadDetails captured;
await pumpMessageList(
  tester,
  // ...
  shouldMarkRead: (details) {
    captured = details;
    return true;
  },
);

expect(captured.unreadCount, 5);
expect(captured.hasSeenFirstUnreadMessage, isFalse);
expect(captured.isMarkedAsUnread, isFalse);
🤖 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_flutter/test/src/message_list_view/mark_read_test.dart`
around lines 328 - 370, Update the two shouldMarkRead override tests to capture
the StreamMarkReadDetails argument and assert its contract instead of only
returning a constant: in the override-blocking test validate the relevant
unread/visibility values, and in the allowing test assert unreadCount is 5,
hasSeenFirstUnreadMessage is false, and isMarkedAsUnread is false. Also add
coverage for an active manual mark-unread followed by a new message, asserting
the details passed through that path.
packages/stream_chat_flutter/lib/src/message_list_view/stream_message_list_view_configuration.dart (1)

223-224: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Document the closure-identity caveat for shouldMarkRead.

Dart compares closures by identity. A predicate written inline in build creates a new closure on every build, so two configurations that are otherwise identical compare unequal. Hosts that rely on configuration equality should hoist the predicate into a field or a static function.

Adding one sentence to the shouldMarkRead doc comment prevents that surprise.

🤖 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_flutter/lib/src/message_list_view/stream_message_list_view_configuration.dart`
around lines 223 - 224, Update the shouldMarkRead documentation near the
configuration equality logic to add one sentence explaining that inline
predicates create new closure identities and can make otherwise identical
configurations unequal. Advise hosts relying on configuration equality to hoist
the predicate into a field or static function; do not change the equality
implementation.
packages/stream_chat_flutter_core/lib/src/stream_channel.dart (1)

873-905: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Note that openAtFirstUnread is read only during initialization.

didUpdateWidget re-initializes the channel only when channel.cid or initialMessageId changes. A host that flips openAtFirstUnread after mount therefore sees no repositioning. That is a reasonable choice, because repositioning on a flag change would move the user's viewport unexpectedly.

Adding one sentence to the property doc ("read once during initialization") removes the ambiguity for host developers.

🤖 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_flutter_core/lib/src/stream_channel.dart` around lines
873 - 905, Update the documentation for the openAtFirstUnread property to state
that it is read only during channel initialization and changes after mount do
not reposition the current viewport. Keep the existing initialization and
didUpdateWidget behavior unchanged.
🤖 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_flutter/lib/src/message_list_view/mark_read_details.dart`:
- Around line 1-6: Update the mark-read type imports to use the package barrel
instead of direct src paths: in stream_message_list_view_configuration.dart,
import StreamMarkReadDetails and StreamShouldMarkReadPredicate from
package:stream_chat_flutter/stream_chat_flutter.dart, and in
mark_read_details.dart either route the StreamMessageListView and
StreamMessageListViewConfiguration.shouldMarkRead dartdoc references through the
barrel or change them to plain text so the docs no longer depend on src-only
symbols.

In
`@packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart`:
- Around line 1105-1113: Move the `_hasSeenFirstUnread.value = true` assignment
in `_onUnreadPillJumpTap` to after the awaited `_scrollToMessage` call, and only
set it when that jump reports success. Preserve the early return for a missing
anchor and keep the unread pill visible when `_scrollToMessage` cannot scroll
because the target or controller is unavailable.
- Around line 1477-1482: Update the isScrolledPast calculation in the
unread-boundary logic to account for widget.config.reverse: retain the current
comparison for reversed lists and use the opposite index-direction comparison
when reverse is false. Preserve the existing isAnchorVisible and return behavior
so _hasSeenFirstUnread only advances after the user actually scrolls past the
anchor in either layout.
- Around line 1443-1454: The _checkMarkUnreadViewportDivergence method currently
compares full ItemPosition values, allowing fractional edge changes to falsely
signal viewport divergence. Update the comparison to use only visible item
indices, or otherwise base divergence on actual scroll activity, while
preserving the initial snapshot and existing _markUnreadViewportDiverged guard
behavior.
- Around line 586-589: Add `_showScrollToBottom.dispose()` to the state teardown
alongside the other notifier disposals, ensuring the ValueNotifier created for
the scroll-to-bottom widget is released when the widget unmounts.

---

Nitpick comments:
In `@packages/stream_chat_flutter_core/lib/src/stream_channel.dart`:
- Around line 873-905: Update the documentation for the openAtFirstUnread
property to state that it is read only during channel initialization and changes
after mount do not reposition the current viewport. Keep the existing
initialization and didUpdateWidget behavior unchanged.

In
`@packages/stream_chat_flutter/lib/src/message_list_view/stream_message_list_view_configuration.dart`:
- Around line 223-224: Update the shouldMarkRead documentation near the
configuration equality logic to add one sentence explaining that inline
predicates create new closure identities and can make otherwise identical
configurations unequal. Advise hosts relying on configuration equality to hoist
the predicate into a field or static function; do not change the equality
implementation.

In `@packages/stream_chat_flutter/test/src/message_list_view/mark_read_test.dart`:
- Around line 328-370: Update the two shouldMarkRead override tests to capture
the StreamMarkReadDetails argument and assert its contract instead of only
returning a constant: in the override-blocking test validate the relevant
unread/visibility values, and in the allowing test assert unreadCount is 5,
hasSeenFirstUnreadMessage is false, and isMarkedAsUnread is false. Also add
coverage for an active manual mark-unread followed by a new message, asserting
the details passed through that path.
🪄 Autofix

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 Plus

Run ID: 871e1dfd-e57c-4606-9922-272ce9385216

📥 Commits

Reviewing files that changed from the base of the PR and between 4b4c2ae and 5c3e35e.

📒 Files selected for processing (33)
  • packages/stream_chat/CHANGELOG.md
  • packages/stream_chat/lib/src/client/channel.dart
  • packages/stream_chat/test/src/client/channel_test.dart
  • packages/stream_chat_flutter/CHANGELOG.md
  • packages/stream_chat_flutter/lib/src/localization/translations.dart
  • packages/stream_chat_flutter/lib/src/message_list_view/mark_read_details.dart
  • packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart
  • packages/stream_chat_flutter/lib/src/message_list_view/mlv_utils.dart
  • packages/stream_chat_flutter/lib/src/message_list_view/stream_message_list_view_configuration.dart
  • packages/stream_chat_flutter/lib/src/message_list_view/unread_indicator_button.dart
  • packages/stream_chat_flutter/lib/src/message_list_view/unread_messages_separator.dart
  • packages/stream_chat_flutter/lib/stream_chat_flutter.dart
  • packages/stream_chat_flutter/test/src/localization/default_translations_test.dart
  • packages/stream_chat_flutter/test/src/message_list_view/mark_read_test.dart
  • packages/stream_chat_flutter/test/src/message_list_view/unread_divider_test.dart
  • packages/stream_chat_flutter/test/src/mocks.dart
  • packages/stream_chat_flutter_core/CHANGELOG.md
  • packages/stream_chat_flutter_core/lib/src/stream_channel.dart
  • packages/stream_chat_localizations/CHANGELOG.md
  • packages/stream_chat_localizations/example/lib/add_new_lang.dart
  • packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart
  • packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart
  • packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart
  • packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart
  • packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart
  • packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart
  • packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart
  • packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart
  • packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart
  • packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart
  • packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart
  • packages/stream_chat_localizations/test/translations_test.dart
  • sample_app/lib/routes/app_routes.dart

Comment thread packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart Outdated
@codecov

codecov Bot commented Aug 6, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.50000% with 25 lines in your changes missing coverage. Please review.
✅ Project coverage is 73.42%. Comparing base (252a472) to head (27e1a51).
⚠️ Report is 2 commits behind head on master.

Files with missing lines Patch % Lines
...r/lib/src/message_list_view/message_list_view.dart 86.76% 18 Missing ⚠️
...t_view/stream_message_list_view_configuration.dart 0.00% 4 Missing ⚠️
...t_flutter/lib/src/message_list_view/mlv_utils.dart 40.00% 3 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #2871      +/-   ##
==========================================
+ Coverage   72.87%   73.42%   +0.54%     
==========================================
  Files         429      430       +1     
  Lines       27716    27850     +134     
==========================================
+ Hits        20199    20448     +249     
+ Misses       7517     7402     -115     

☔ 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.

@renefloor
renefloor enabled auto-merge (squash) August 7, 2026 08:01
@renefloor
renefloor disabled auto-merge August 7, 2026 08:01
Missed in the previous review-comment pass; it's created alongside the
other mark-read/unread notifiers and needs the same teardown.
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.

1 participant