From c54721e637ca9119524c0f8f19bd5eb7f5c541ea Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Thu, 6 Aug 2026 12:35:16 +0200 Subject: [PATCH 01/14] feat(llc): add ChannelClientState.isMarkedAsUnread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/stream_chat/CHANGELOG.md | 1 + .../stream_chat/lib/src/client/channel.dart | 28 +++++++++++++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index 83018bf1b6..ba8648024f 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -5,6 +5,7 @@ - Added `StreamChatClient.isLocalUnreadCountEnabled` (default `false`). When enabled, channels that have read events disabled (e.g. livestream channel types) track their unread count locally, on-device: incoming messages increment it, hard-deleted messages decrement it, and `Channel.markRead` / `markUnread` / `markUnreadByTimestamp` update it locally without a network request — including `Read.lastReadMessageId`, so the unread divider and jump-to-unread button anchor to the right message. Channels that support read receipts are unaffected and keep relying on server-driven unread counts. - Added `Event.watcherCount`, exposing the server-provided `watcher_count` field on events (e.g. `user.watching.start`, `user.watching.stop`, `message.new`). - Added `StreamChatNetworkError.type` (a `StreamChatNetworkErrorType` capturing the transport failure kind — connection error, timeout, cancellation, etc.). +- Added `ChannelClientState.isMarkedAsUnread`, reporting whether the current user has an active manual mark-unread on the channel that hasn't been read past yet. 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. ⚠️ Deprecated diff --git a/packages/stream_chat/lib/src/client/channel.dart b/packages/stream_chat/lib/src/client/channel.dart index 6bacb60a94..783cd79a02 100644 --- a/packages/stream_chat/lib/src/client/channel.dart +++ b/packages/stream_chat/lib/src/client/channel.dart @@ -3473,10 +3473,12 @@ class ChannelClientState { updateRead([updatedRead]); // If the read event is from the current user, reconcile the - // channel delivery status with the updated read state. + // channel delivery status with the updated read state, and clear + // any pending manual mark-unread — the user has read past it. final currentUser = _client.state.currentUser; if (event.isFromUser(userId: currentUser?.id)) { _client.channelDeliveryReporter.reconcileDelivery([_channel]); + _isMarkedAsUnread = false; } }, ), @@ -3499,7 +3501,14 @@ class ChannelClientState { lastDeliveredMessageId: currentRead?.lastDeliveredMessageId, ); - return updateRead([updatedRead]); + updateRead([updatedRead]); + + // Only a mark-unread for the current user's own read state + // should gate this device's auto mark-read. + final currentUser = _client.state.currentUser; + if (event.isFromUser(userId: currentUser?.id)) { + _isMarkedAsUnread = true; + } }, ), ) @@ -3662,6 +3671,18 @@ class ChannelClientState { return updateRead([existingUserRead.copyWith(unreadMessages: count)]); } + /// Whether the current user explicitly marked a message in this channel as + /// unread during this session, without having read past that boundary + /// since. + /// + /// 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 — mirrors the iOS SDK's + /// `ReadStateHandler.isMarkedAsUnread`. + bool get isMarkedAsUnread => _isMarkedAsUnread; + bool _isMarkedAsUnread = false; + /// Marks the channel as read locally, without making a network request. /// /// Used for channels that track unread counts locally (see @@ -3700,6 +3721,8 @@ class ChannelClientState { // locally can still have delivery receipts enabled. Mirrors what the // `message.read` event listener does for server-driven channels. _client.channelDeliveryReporter.reconcileDelivery([_channel]); + + _isMarkedAsUnread = false; } /// Marks the channel as unread locally, without making a network request. @@ -3738,6 +3761,7 @@ class ChannelClientState { final unread = messages.where((it) => MessageRules.canCountAsUnread(it, _channel)).length; unreadCount = unread; + _isMarkedAsUnread = true; } /// Counts the number of unread messages mentioning the current user. From 6035a2c32ab0fc36303d79383160232cf81fcae0 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Thu, 6 Aug 2026 12:35:37 +0200 Subject: [PATCH 02/14] feat(core): add StreamChannel.openAtFirstUnread 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 --- .../stream_chat_flutter_core/CHANGELOG.md | 1 + .../lib/src/stream_channel.dart | 68 ++++++++++++------- sample_app/lib/routes/app_routes.dart | 1 + 3 files changed, 45 insertions(+), 25 deletions(-) diff --git a/packages/stream_chat_flutter_core/CHANGELOG.md b/packages/stream_chat_flutter_core/CHANGELOG.md index abc81cf51d..eb414e7d3f 100644 --- a/packages/stream_chat_flutter_core/CHANGELOG.md +++ b/packages/stream_chat_flutter_core/CHANGELOG.md @@ -4,6 +4,7 @@ - Added `StreamChannelState.retry()` to re-run a failed channel initialization, for use as the retry action in `StreamChannel.errorBuilder`. - Added `DefaultStreamChannelBuilders`, an inherited widget that supplies default loading and error builders to descendant `StreamChannel`s (resolved via `loadingBuilderOf`/`errorBuilderOf`). +- Added `StreamChannel.openAtFirstUnread`, defaulting to `true` (preserving existing behavior). Set to `false` to always open a channel at the latest message, instead of scrolling to the first pre-existing unread message. 🐞 Fixed diff --git a/packages/stream_chat_flutter_core/lib/src/stream_channel.dart b/packages/stream_chat_flutter_core/lib/src/stream_channel.dart index 1f6c64f2e1..5e4232f356 100644 --- a/packages/stream_chat_flutter_core/lib/src/stream_channel.dart +++ b/packages/stream_chat_flutter_core/lib/src/stream_channel.dart @@ -37,6 +37,7 @@ class StreamChannel extends StatefulWidget { required this.channel, this.showLoading = true, this.initialMessageId, + this.openAtFirstUnread = true, this.errorBuilder = _resolveErrorBuilder, this.loadingBuilder = _resolveLoadingBuilder, }) : _shouldPosition = true; @@ -60,6 +61,7 @@ class StreamChannel extends StatefulWidget { required this.channel, }) : showLoading = false, initialMessageId = null, + openAtFirstUnread = true, errorBuilder = _resolveErrorBuilder, loadingBuilder = _resolveLoadingBuilder, _shouldPosition = false; @@ -76,6 +78,18 @@ class StreamChannel extends StatefulWidget { /// If passed the channel will load from this particular message. final String? initialMessageId; + /// Whether the channel should open positioned at the first unread message + /// when it has pre-existing unread messages. + /// + /// Defaults to `true`, preserving the SDK's existing behaviour. Set to + /// `false` to always open at the latest message instead — the message + /// list then surfaces pre-existing unread via its unread divider and + /// jump-to-unread pill rather than by scrolling there automatically. + /// + /// Has no effect on [StreamChannel.value], which never repositions the + /// loaded window. + final bool openAtFirstUnread; + /// Widget builder used while the channel is initialising. /// /// Defaults to a builder that resolves the nearest @@ -856,33 +870,37 @@ class StreamChannelState extends State { return loadChannelAtMessage(initialMessageId); } - // Otherwise, we should load the channel at the first unread - // message if available. - if (channel.state case final state? when state.unreadCount > 0) { - final currentUserRead = state.currentUserRead; - - // Skip if we don't have read state for the current user. - if (currentUserRead == null) return; - - // Load the channel at the last read message if available. - if (currentUserRead.lastReadMessageId case final lastReadMessageId?) { - try { - return await loadChannelAtMessage(lastReadMessageId); - } catch (e) { - // If the loadChannelAtMessage for any reason fails, we fallback to - // loading the channel at the last read date. - // - // One example of this is when the channel becomes too large and - // exceeds a certain threshold (I believe it's a 1000 members) it - // can't update the readstate anymore for each individual member. + // Otherwise, we should load the channel at the first unread message if + // available — unless the caller opted out via + // [StreamChannel.openAtFirstUnread], in which case we fall through to + // load-latest below. + if (widget.openAtFirstUnread) { + if (channel.state case final state? when state.unreadCount > 0) { + final currentUserRead = state.currentUserRead; + + // Skip if we don't have read state for the current user. + if (currentUserRead == null) return; + + // Load the channel at the last read message if available. + if (currentUserRead.lastReadMessageId case final lastReadMessageId?) { + try { + return await loadChannelAtMessage(lastReadMessageId); + } catch (e) { + // If the loadChannelAtMessage for any reason fails, we fallback to + // loading the channel at the last read date. + // + // One example of this is when the channel becomes too large and + // exceeds a certain threshold (I believe it's a 1000 members) it + // can't update the readstate anymore for each individual member. + } } - } - // Skip the "never read" sentinel: the server ignores it as - // `created_at_around` and returns the tail, which would mis-infer - // `_topPaginationEnded = true`. Fall through to load-latest below. - if (currentUserRead.lastRead.isAfter(_minValidLastRead)) { - return loadChannelAtTimestamp(currentUserRead.lastRead); + // Skip the "never read" sentinel: the server ignores it as + // `created_at_around` and returns the tail, which would mis-infer + // `_topPaginationEnded = true`. Fall through to load-latest below. + if (currentUserRead.lastRead.isAfter(_minValidLastRead)) { + return loadChannelAtTimestamp(currentUserRead.lastRead); + } } } diff --git a/sample_app/lib/routes/app_routes.dart b/sample_app/lib/routes/app_routes.dart index 3e11a35f2f..ca603bd580 100644 --- a/sample_app/lib/routes/app_routes.dart +++ b/sample_app/lib/routes/app_routes.dart @@ -42,6 +42,7 @@ final appRoutes = [ return StreamChannel( channel: channel, initialMessageId: messageId, + openAtFirstUnread: false, child: Builder( builder: (context) { return (parentMessage != null) From 2622109e3bfe046ed55d07aa2596604eb2e24529 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Thu, 6 Aug 2026 12:45:28 +0200 Subject: [PATCH 03/14] feat(ui): rework unread indicators and tighten mark-read gating (FLU-648/649/650/640) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/stream_chat_flutter/CHANGELOG.md | 12 + .../lib/src/localization/translations.dart | 10 + .../message_list_view/mark_read_details.dart | 42 ++ .../message_list_view/message_list_view.dart | 489 ++++++++++++++---- .../lib/src/message_list_view/mlv_utils.dart | 17 +- ...tream_message_list_view_configuration.dart | 20 +- .../unread_indicator_button.dart | 53 +- .../unread_messages_separator.dart | 6 +- .../lib/stream_chat_flutter.dart | 2 + .../default_translations_test.dart | 2 + .../src/message_list_view/mark_read_test.dart | 366 ++++++++++++- .../unread_divider_test.dart | 429 +++++++++++++++ .../stream_chat_flutter/test/src/mocks.dart | 1 + 13 files changed, 1298 insertions(+), 151 deletions(-) create mode 100644 packages/stream_chat_flutter/lib/src/message_list_view/mark_read_details.dart create mode 100644 packages/stream_chat_flutter/test/src/message_list_view/unread_divider_test.dart diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index 2220c44b44..f5814518bd 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -8,6 +8,18 @@ - Added a `size` (`StreamLoadingSpinnerSize`) parameter to `StreamScrollViewLoadingWidget`. - Added `onReactionTap` to `StreamMessageItem` and `StreamMessageListView`, reporting the tapped message's `BuildContext` and a `ReactionTapDetails` with the tapped `message` and `reaction` (the reaction is `null` for a clustered or overflow chip that maps to no single reaction). - Added an `unreadIndicator` parameter to `StreamBackButton` that overlays a widget (typically a `StreamUnreadIndicator`) on the button's top-end corner. Pass `StreamUnreadIndicator(excludeCid: cid)` to show the total unread count of other channels, or `StreamUnreadIndicator.channels(cid: cid)` for a single channel's count. +- Added `StreamMessageListViewConfiguration.shouldMarkRead` to fully override the automatic mark-read gating described below. +- Added `Channel.isMarkedAsUnread` (via `ChannelClientState`), reporting whether the current user has an active manual mark-unread that hasn't been read past yet. +- Added `StreamChannel.openAtFirstUnread` (`stream_chat_flutter_core`), defaulting to `true`. Set to `false` to always open a channel at the latest message instead of scrolling to the first pre-existing unread message. +- Added `Translations.unreadMessagesSeparatorLabel`, used by the default `UnreadMessagesSeparator` to show a count, e.g. "5 unread messages". + +🔄 Changed + +- Changed the "↑ N unread" jump-to-unread pill to a count fixed when the channel opens, staying on screen for the whole session rather than reacting to the live, shrinking unread count. The pill now shows as soon as that count is known — even before the boundary message itself has loaded — and dismisses permanently for the session once tapped, dismissed, or scrolled past; it no longer reappears when a new message arrives. +- Changed the scroll-to-bottom badge to count only messages that arrive out of view during the current session, rather than being seeded from the channel's unread count. It always resets to 0 once the user reaches the bottom. +- Changed the "unread messages" divider to show a count, starting at the channel's open-time unread total and counting up as further messages arrive during the session — mirroring WhatsApp — instead of a fixed, count-less label. +- Changed `UnreadIndicatorButton` to take a `required int unreadCount` and render unconditionally, dropping its internal read-state subscription — `StreamMessageListView` now owns its visibility. +- Tightened `StreamMessageListView`'s automatic mark-read gating to also require that the pre-existing unread boundary (if any) has been seen or scrolled past, and that there's no pending manual mark-unread — mirroring the iOS SDK. Previously, reaching the bottom with unread messages present was sufficient. ⚠️ Deprecated diff --git a/packages/stream_chat_flutter/lib/src/localization/translations.dart b/packages/stream_chat_flutter/lib/src/localization/translations.dart index d495e6564f..0a96e78e6b 100644 --- a/packages/stream_chat_flutter/lib/src/localization/translations.dart +++ b/packages/stream_chat_flutter/lib/src/localization/translations.dart @@ -102,6 +102,10 @@ abstract class Translations { /// in the [StreamMessageListView] String unreadMessagesSeparatorText(); + /// The label for the unread messages separator in the + /// [StreamMessageListView], e.g. "5 unread messages". + String unreadMessagesSeparatorLabel({required int count}); + /// The label for "connected" in [StreamConnectionStatusBuilder] String get connectedLabel; @@ -1290,6 +1294,12 @@ Attachment limit exceeded: it's not possible to add more than $limit attachments @override String unreadMessagesSeparatorText() => 'New messages'; + @override + String unreadMessagesSeparatorLabel({required int count}) { + if (count == 1) return '1 unread message'; + return '$count unread messages'; + } + @override String get enableFileAccessMessage => 'Please enable access to files' diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/mark_read_details.dart b/packages/stream_chat_flutter/lib/src/message_list_view/mark_read_details.dart new file mode 100644 index 0000000000..d2b4fda27d --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_list_view/mark_read_details.dart @@ -0,0 +1,42 @@ +/// The information available when deciding whether to automatically mark a +/// [StreamMessageListView]'s channel as read. +/// +/// Passed to a caller-supplied predicate on +/// [StreamMessageListViewConfiguration.shouldMarkRead]. Not intended to be +/// constructed directly. +class StreamMarkReadDetails { + /// Creates a set of details describing the current mark-read gate state. + const StreamMarkReadDetails({ + required this.hasSeenLastMessage, + required this.hasSeenFirstUnreadMessage, + required this.isMarkedAsUnread, + required this.unreadCount, + }); + + /// Whether the bottom of the list has been fully visible at some point + /// since the last successful mark-read — either it's visible right now, or + /// it was visible earlier and the user has since scrolled away. + final bool hasSeenLastMessage; + + /// Whether the user has seen (rendered on screen) or scrolled past the + /// pre-existing unread boundary captured when the channel was opened. + /// + /// Always `true` when there was nothing to see in the first place — the + /// channel opened fully read, or it uses local unread counts with read + /// events disabled. + final bool hasSeenFirstUnreadMessage; + + /// Whether the current user has an active manual mark-unread on this + /// channel that hasn't been read past yet. + final bool isMarkedAsUnread; + + /// The channel's current unread count. + final int unreadCount; +} + +/// Signature for overriding [StreamMessageListView]'s automatic mark-read +/// gating. +/// +/// Return `true` to mark the channel as read, `false` to skip it for now — +/// the list retries on the next relevant scroll or message event. +typedef StreamShouldMarkReadPredicate = bool Function(StreamMarkReadDetails details); diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart index c8ecf79d75..e84be35481 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart @@ -12,7 +12,6 @@ import 'package:stream_chat_flutter/src/message_list_view/mlv_utils.dart'; import 'package:stream_chat_flutter/src/message_list_view/stream_message_list_empty_state.dart'; import 'package:stream_chat_flutter/src/message_list_view/stream_message_list_skeleton_loading.dart'; import 'package:stream_chat_flutter/src/message_list_view/thread_separator.dart'; -import 'package:stream_chat_flutter/src/message_list_view/unread_messages_separator.dart'; import 'package:stream_chat_flutter/src/message_widget/stream_ephemeral_message.dart'; import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; import 'package:stream_chat_flutter/src/utils/network_error_text.dart'; @@ -295,17 +294,144 @@ class _StreamMessageListViewState extends State { late final ItemPositionsListener _itemPositionListener; StreamChannelState? streamChannel; - // Drives the unread-messages separator. Held in a [ValueNotifier] so read - // events can update it without rebuilding the entire list view. - final _unreadState = ValueNotifier<({int count, String? firstUnreadId})>((count: 0, firstUnreadId: null)); + // --- Divider A: pre-existing unread, frozen at channel open --- + // + // [_unreadBaseline] is the current user's [Read] captured once when the + // channel is attached (or on the first `currentUserReadStream` emission if + // read state wasn't available yet). It never changes afterwards, so + // resolving the anchor against it — rather than against the live, + // ever-shrinking `unreadCount` — is what keeps the divider and pill on + // screen across an auto mark-read. + Read? _unreadBaseline; + bool _unreadBaselineCaptured = false; + + // Resolved anchor for divider A. The anchor (and `count`, the frozen + // baseline used by the pill) is frozen once non-null: recomputation is + // skipped as soon as `anchorId` is set. May take a few rebuilds to resolve + // if top pagination hasn't finished loading the boundary yet. + final _unreadDivider = ValueNotifier<({int count, String? anchorId})>((count: 0, anchorId: null)); + + // Grows by one for every message that arrives out of view while divider + // A is on screen, so the divider's displayed count keeps counting up + // during the session (mirroring WhatsApp) instead of staying frozen at + // the open-time count. Added on top of `_unreadDivider.value.count` for + // display only — the pill keeps using the frozen count. + final ValueNotifier _unreadDividerGrowth = ValueNotifier(0); + + // Sticky: becomes true once the user has seen (rendered) or scrolled past + // divider A's anchor. Drives the pill's permanent dismissal and (see + // [_maybeMarkMessagesAsRead]) gates auto mark-read. + final ValueNotifier _hasSeenFirstUnread = ValueNotifier(false); + + // Scroll-to-bottom badge count. Counts messages that arrive while the user + // is scrolled away from the bottom; resets to 0 once they reach the bottom. + final ValueNotifier _scrollToBottomBadge = ValueNotifier(0); + + // Sticky "bottom was reached" flag for the FLU-640 mark-read gate. Cleared + // after each successful mark-read so returning to the bottom is required + // again before the next one. + bool _hasSeenLastMessage = false; + + // While non-null, the viewport captured at the moment an active manual + // mark-unread (`channel.state.isMarkedAsUnread`) was first observed. + // `_maybeMarkMessagesAsRead` blocks until [_markUnreadViewportDiverged] + // is true — evidence the user did something (scrolled, reopened the + // channel, etc.) since marking the message unread, rather than the + // anchor merely being immediately "visible" again because it's usually + // the very message just marked and nothing has moved. + // + // This can't gate on `isMarkedAsUnread` directly and permanently: that + // flag only clears via a successful mark-read, which is the very thing + // it would be gating, so treating it as a persistent block would + // deadlock the channel unread forever the moment it's set — the exact + // bug this snapshot exists to avoid. + // + // Set eagerly in [_handleCurrentUserReadChanged] right when a live + // transition is observed (captures the precise pre-scroll viewport), and + // in [_handleItemPositionsChanged] on the first genuinely laid-out frame + // as a fallback for when the channel simply mounts with + // `isMarkedAsUnread` already true and no transition ever fires — that + // has to happen there and not lazily inside [_maybeMarkMessagesAsRead], + // since the first time that gate is evaluated might already be the + // user's first genuine arrival at the bottom, which would otherwise be + // burned on capturing the baseline instead of acting on it. Cleared once + // a mark-read actually goes through, or once `isMarkedAsUnread` itself + // clears (so a future mark-unread starts its own fresh snapshot). + Iterable? _markUnreadViewportSnapshot; + + // Sticky once true: sighted the first time [_handleItemPositionsChanged] + // (or, as a fallback, [_maybeMarkMessagesAsRead] itself) sees item + // positions that genuinely differ from [_markUnreadViewportSnapshot]. + // Deliberately tracked as "did this ever happen" rather than + // re-comparing the *current* positions against the snapshot on each + // check — a user who scrolls away and back settles at the exact same + // rest position, which would otherwise look unchanged and re-block a + // mark-read that should already have been earned by that round trip. + bool _markUnreadViewportDiverged = false; + + // Captures [_unreadBaseline] the first time the current user's read state + // becomes available, then attempts to resolve divider A's anchor against + // it. No-ops in a thread, where divider A doesn't apply. + void _captureUnreadBaselineIfNeeded() { + if (_unreadBaselineCaptured || _isThreadConversation) return; + + final currentUserRead = streamChannel?.channel.state?.currentUserRead; + if (currentUserRead == null) return; + + _unreadBaselineCaptured = true; + _unreadBaseline = currentUserRead.unreadMessages > 0 ? currentUserRead : null; + // Publish the frozen count right away, even though the anchor itself + // can't resolve until top pagination has loaded that far back — the + // pill only needs the count, not the anchor, so it shouldn't wait on + // pagination to appear (see `_onUnreadPillJumpTap` for how a tap + // before the anchor resolves still jumps there). + if (_unreadBaseline case final baseline?) { + _unreadDivider.value = (count: baseline.unreadMessages, anchorId: _unreadDivider.value.anchorId); + } + _resolveUnreadDivider(); + } - // Snapshot of the current user's unread state, sourced from the channel. Used - // both to seed [_unreadState] on channel attach and to refresh it from the - // [Channel.currentUserReadStream] listener. - ({int count, String? firstUnreadId}) _readUnreadSnapshot() => ( - count: streamChannel?.channel.state?.unreadCount ?? 0, - firstUnreadId: streamChannel?.getFirstUnreadMessage()?.id, - ); + // Resolves divider A's anchor against the frozen baseline. A no-op once + // resolved, and while top pagination hasn't loaded the boundary yet. + void _resolveUnreadDivider() { + if (_isThreadConversation || _unreadDivider.value.anchorId != null) return; + + final baseline = _unreadBaseline; + if (baseline == null) return; + + final anchor = streamChannel?.getFirstUnreadMessage(baseline); + if (anchor == null) return; + + _unreadDivider.value = (count: baseline.unreadMessages, anchorId: anchor.id); + } + + // Reacts to a `currentUserReadStream` emission. An explicit mark-unread + // moves the read boundary backward — treat it as a new session start for + // divider A/the pill, mirroring iOS's `forceUpdate` path. + void _handleCurrentUserReadChanged() { + if (_isThreadConversation) return; + + final channel = streamChannel?.channel; + if (channel == null) return; + + if (channel.state?.isMarkedAsUnread ?? false) { + _unreadBaselineCaptured = false; + _unreadBaseline = null; + _unreadDivider.value = (count: 0, anchorId: null); + _unreadDividerGrowth.value = 0; + _hasSeenFirstUnread.value = false; + // Only capture once per mark-unread session — a later, unrelated + // read-stream emission while still marked unread shouldn't keep + // chasing the latest position and never let a genuine scroll differ + // from it. + _markUnreadViewportSnapshot ??= _itemPositionListener.itemPositions.value.toList(); + } else { + _markUnreadViewportSnapshot = null; + _markUnreadViewportDiverged = false; + } + + _captureUnreadBaselineIfNeeded(); + } bool get _upToDate => streamChannel!.channel.state!.isUpToDate; @@ -361,7 +487,16 @@ class _StreamMessageListViewState extends State { debouncedMarkRead.cancel(); debouncedMarkThreadRead.cancel(); - _unreadState.value = _readUnreadSnapshot(); + _unreadBaselineCaptured = false; + _unreadBaseline = null; + _unreadDivider.value = (count: 0, anchorId: null); + _unreadDividerGrowth.value = 0; + _scrollToBottomBadge.value = 0; + _hasSeenFirstUnread.value = false; + _hasSeenLastMessage = false; + _markUnreadViewportSnapshot = null; + _markUnreadViewportDiverged = false; + _captureUnreadBaselineIfNeeded(); final highlightInitialMessage = widget.config.highlightInitialMessage; final highlightMessageId = switch ((highlightInitialMessage, _isThreadConversation)) { @@ -392,6 +527,21 @@ class _StreamMessageListViewState extends State { final currentUser = streamChannel?.channel.client.state.currentUser; final isAtBottom = !_showScrollToBottom.value; + // The scroll-to-bottom badge and divider A's growing count only + // apply to the channel's own message stream (not thread replies), + // and never count the current user's own messages. + final isOwnMessage = message.user?.id == currentUser?.id; + if (!_isThreadConversation && !isOwnMessage) { + // The divider counts every qualifying arrival — including ones + // seen live at the bottom — so it keeps counting up like + // WhatsApp's. The badge is narrower: it only exists to flag + // what was missed while scrolled away, so it skips arrivals + // that were already in view and resets once the bottom is + // reached (see `_handleItemPositionsChanged`). + _unreadDividerGrowth.value += 1; + if (!isAtBottom) _scrollToBottomBadge.value += 1; + } + final details = StreamAutoScrollDetails( message: message, currentUser: currentUser, @@ -417,14 +567,14 @@ class _StreamMessageListViewState extends State { _userReadListener?.cancel(); _userReadListener = state?.currentUserReadStream.listen((_) { - _unreadState.value = _readUnreadSnapshot(); + _handleCurrentUserReadChanged(); }); } } @override void dispose() { - // Tear down anything that could write to [_unreadState] or + // Tear down anything that could write to the unread/badge notifiers or // [_showScrollToBottom] before disposing them. _messageNewListener?.cancel(); _messageNewListener = null; @@ -433,7 +583,10 @@ class _StreamMessageListViewState extends State { _itemPositionListener.itemPositions.removeListener(_handleItemPositionsChanged); debouncedMarkRead.cancel(); debouncedMarkThreadRead.cancel(); - _unreadState.dispose(); + _unreadDivider.dispose(); + _unreadDividerGrowth.dispose(); + _hasSeenFirstUnread.dispose(); + _scrollToBottomBadge.dispose(); _highlightState.dispose(); super.dispose(); } @@ -591,6 +744,17 @@ class _StreamMessageListViewState extends State { Widget _buildListView(List data) { messages = data; + // Top pagination may not have finished loading the unread boundary when + // the baseline was first captured; retry once this frame's layout + // settles. Deferred (not synchronous) since mutating a [ValueNotifier] + // read by a [ValueListenableBuilder] further down this same build would + // notify a listener that hasn't rebuilt yet this frame. + if (_unreadBaseline != null && _unreadDivider.value.anchorId == null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _resolveUnreadDivider(); + }); + } + final itemCount = messages.length + // total messages 2 + // top + bottom loading indicator @@ -816,9 +980,30 @@ class _StreamMessageListViewState extends State { if (widget.config.showUnreadIndicator && !_isThreadConversation) Positioned( top: context.streamSpacing.sm, - child: UnreadIndicatorButton( - onJumpTap: scrollToUnreadDefaultTapAction, - onDismissTap: _markMessagesAsRead, + child: ValueListenableBuilder( + valueListenable: _unreadDivider, + builder: (context, unread, _) { + // Gated on the frozen count, not the anchor: the count is + // known immediately from the baseline `Read`, while the + // anchor can take a while longer to resolve if top + // pagination hasn't loaded that far back yet. Waiting for + // the anchor would mean the pill — the whole point of + // which is to point at unread content the user hasn't + // scrolled to — only appeared once they'd already + // scrolled most of the way there themselves. + if (unread.count <= 0) return const Empty(); + return ValueListenableBuilder( + valueListenable: _hasSeenFirstUnread, + builder: (context, seen, __) { + if (seen) return const Empty(); + return UnreadIndicatorButton( + unreadCount: unread.count, + onJumpTap: _onUnreadPillJumpTap, + onDismissTap: _onUnreadPillDismissTap, + ); + }, + ); + }, ), ), ], @@ -861,33 +1046,32 @@ class _StreamMessageListViewState extends State { } Widget _buildUnreadMessagesSeparator(int unreadCount) { - if (widget.builders.unreadMessagesSeparator != null) { - return widget.builders.unreadMessagesSeparator!(context, unreadCount); + if (widget.builders.unreadMessagesSeparator case final builder?) { + return builder(context, unreadCount); } return UnreadMessagesSeparator(unreadCount: unreadCount); } // Wraps an already-built [separator] with the unread-messages line if - // [message] happens to be the first unread one. Defined as a method - // (rather than a closure inside [separatorBuilder]) so a fresh inner - // closure isn't allocated for every visible separator on every rebuild. + // [message] happens to be divider A's anchor. Defined as a method (rather + // than a closure inside [separatorBuilder]) so a fresh inner closure isn't + // allocated for every visible separator on every rebuild. Widget _maybeBuildWithUnreadMessagesSeparator({ required Message message, required Widget separator, }) { if (_isThreadConversation) return separator; return ValueListenableBuilder( - valueListenable: _unreadState, - builder: (context, state, _) { - if (state.count == 0) return separator; - if (state.firstUnreadId != message.id) return separator; - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - separator, - _buildUnreadMessagesSeparator(state.count), - ], + valueListenable: _unreadDivider, + builder: (context, unread, _) { + if (unread.anchorId != message.id) return separator; + return ValueListenableBuilder( + valueListenable: _unreadDividerGrowth, + builder: (context, growth, __) => Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [separator, _buildUnreadMessagesSeparator(unread.count + growth)], + ), ); }, ); @@ -913,20 +1097,25 @@ class _StreamMessageListViewState extends State { } } - Future scrollToUnreadDefaultTapAction(String? lastReadMessageId) async { - final firstUnreadId = _unreadState.value.firstUnreadId; - if (firstUnreadId == null) return; - - // Scroll to the first unread message in the list. - final firstUnreadMessageIndex = messages.lastIndexWhere((it) => it.id == firstUnreadId); - if (firstUnreadMessageIndex == -1) return; + Future _onUnreadPillJumpTap() async { + // The anchor may not have resolved yet if top pagination hasn't loaded + // that far back — the pill is visible already (see its gating above), + // so fall back to the frozen baseline's own last-read boundary, known + // immediately from the server `Read`, rather than doing nothing. + final anchorId = _unreadDivider.value.anchorId ?? _unreadBaseline?.lastReadMessageId; + if (anchorId == null) return; + + _hasSeenFirstUnread.value = true; + // Delegates to [_scrollToMessage], which falls back to + // [StreamChannelState.loadChannelAtMessage] when the anchor isn't in the + // currently loaded window — after which the real anchor resolves + // naturally via the retry in [_buildListView], rendering divider A too. + await _scrollToMessage(messageId: anchorId, highlight: false); + } - if (_scrollController case final controller? when controller.isAttached) { - return controller.scrollTo( - index: max(firstUnreadMessageIndex + 2, 0), - alignment: 0.5, // center the message in the viewport - ); - } + Future _onUnreadPillDismissTap() async { + _hasSeenFirstUnread.value = true; + await _markMessagesAsRead(); } late final debouncedMarkRead = debounce( @@ -1062,15 +1251,14 @@ class _StreamMessageListViewState extends State { } Widget _buildScrollToBottom() { - return ValueListenableBuilder( - valueListenable: _unreadState, - builder: (_, state, __) { - final unreadCount = state.count; + return ValueListenableBuilder( + valueListenable: _scrollToBottomBadge, + builder: (_, badgeCount, __) { if (widget.builders.scrollToBottomButton case final builder?) { - return builder(unreadCount, scrollToBottomDefaultTapAction); + return builder(badgeCount, scrollToBottomDefaultTapAction); } - final showUnreadCount = unreadCount > 0; + final showUnreadCount = badgeCount > 0; Widget button = StreamButton.icon( style: .secondary, @@ -1081,12 +1269,12 @@ class _StreamMessageListViewState extends State { true => Icon(context.streamIcons.arrowDown), false => Icon(context.streamIcons.arrowUp), }, - onPressed: () => scrollToBottomDefaultTapAction(unreadCount), + onPressed: () => scrollToBottomDefaultTapAction(badgeCount), ); if (showUnreadCount && widget.config.showUnreadCountOnScrollToBottom) { button = StreamBadgeNotification( - label: '${unreadCount > 99 ? '99+' : unreadCount}', + label: '${badgeCount > 99 ? '99+' : badgeCount}', child: button, ); } @@ -1197,6 +1385,23 @@ class _StreamMessageListViewState extends State { final itemPositions = _itemPositionListener.itemPositions.value; if (itemPositions.isEmpty) return; + // Snapshot the viewport (or check it against an existing snapshot for + // divergence) the first time it's genuinely laid out while marked as + // unread, in case the channel simply mounted in that state rather than + // [_handleCurrentUserReadChanged] observing a live transition to hook + // the snapshot on. Doing this here — on every non-empty layout, before + // checking anything else below — rather than lazily inside + // [_maybeMarkMessagesAsRead], matters: that gate is only ever evaluated + // when a mark-read could fire, which for a channel the user opens and + // immediately scrolls all the way through might be the very first time + // they reach the bottom. Capturing the baseline there would burn that + // first genuine read on the snapshot itself instead of acting on it. + if (streamChannel?.channel.state?.isMarkedAsUnread ?? false) { + _checkMarkUnreadViewportDivergence(itemPositions); + } + + final justSeenFirstUnread = _maybeUpdateHasSeenFirstUnread(itemPositions); + // Index of the last item in the list view is 2 as 1 is the progress // indicator and 0 is the footer. const lastItemIndex = 2; @@ -1212,61 +1417,163 @@ class _StreamMessageListViewState extends State { } if (mounted) _showScrollToBottom.value = !isLastItemFullyVisible; - if (isLastItemFullyVisible) return _handleLastItemFullyVisible(); - } - - Message? _lastFullyVisibleMessage; - void _handleLastItemFullyVisible() { - // We are using the first message as the last fully visible message - // because the messages are reversed in the list view. - final newLastFullyVisibleMessage = messages.firstOrNull; + if (isLastItemFullyVisible) { + _hasSeenLastMessage = true; + _scrollToBottomBadge.value = 0; + } - final lastFullyVisibleMessageChanged = switch (_lastFullyVisibleMessage) { - final message? => message.id != newLastFullyVisibleMessage?.id, - null => true, // Allows setting the initial value. - }; + // Attempt a mark-read whenever either half of the FLU-640 gate could + // have just become satisfied; `_maybeMarkMessagesAsRead` does the actual + // deciding, and the leading-edge debounce inside it makes repeated + // attempts cheap. + if ((isLastItemFullyVisible || justSeenFirstUnread) && widget.config.markReadWhenAtTheBottom) { + _maybeMarkMessagesAsRead().ignore(); + } + } - // If the last fully visible message has been changed, we need to update the - // value and maybe mark messages as read if needed. - if (lastFullyVisibleMessageChanged) { - _lastFullyVisibleMessage = newLastFullyVisibleMessage; + // Captures [_markUnreadViewportSnapshot] the first time this is called, + // and otherwise checks [itemPositions] against it, latching + // [_markUnreadViewportDiverged] the first time they genuinely differ. + // Deliberately latching rather than re-comparing *current* positions + // against the snapshot on every check: a user who scrolls away and back + // settles at the exact same rest position, which would otherwise look + // unchanged and re-block a mark-read the round trip should already have + // earned. Safe to call on every position-changed tick — a no-op once + // already diverged. + void _checkMarkUnreadViewportDivergence(Iterable itemPositions) { + if (_markUnreadViewportSnapshot == null) { + _markUnreadViewportSnapshot = itemPositions.toList(); + return; + } + if (_markUnreadViewportDiverged) return; - // Mark messages as read if needed. - if (widget.config.markReadWhenAtTheBottom) { - _maybeMarkMessagesAsRead().ignore(); - } + const positionsEquality = UnorderedIterableEquality(); + if (!positionsEquality.equals(itemPositions, _markUnreadViewportSnapshot)) { + _markUnreadViewportDiverged = true; } } + // Marks divider A's anchor as seen once it renders on screen, or once the + // user scrolls past it without it ever rendering (a fast fling can skip + // intermediate frames). Sticky: never reverts once true, and reset only + // when the baseline is recaptured (channel change, or an explicit + // mark-unread — see [_handleCurrentUserReadChanged]). + // + // Returns true iff this call flips [_hasSeenFirstUnread] from false to + // true. + bool _maybeUpdateHasSeenFirstUnread(Iterable itemPositions) { + if (_isThreadConversation || _hasSeenFirstUnread.value) return false; + + final anchorId = _unreadDivider.value.anchorId; + if (anchorId == null) return false; + + final anchorMessageIndex = messages.indexWhere((it) => it.id == anchorId); + if (anchorMessageIndex == -1) return false; + final anchorItemIndex = anchorMessageIndex + 2; + + final visibleIndices = itemPositions.map((position) => position.index).toList(); + if (visibleIndices.isEmpty) return false; + + final isAnchorVisible = visibleIndices.contains(anchorItemIndex); + // Smaller item indices are newer/closer to the bottom. If even the + // newest visible item is older than the anchor, the anchor has scrolled + // off the bottom of the viewport — the user scrolled past it. + final isScrolledPast = visibleIndices.reduce(min) > anchorItemIndex; + if (!isAnchorVisible && !isScrolledPast) return false; + + _hasSeenFirstUnread.value = true; + return true; + } + // Marks messages as read if the conditions are met. // - // The conditions are: - // 1. The channel is up to date or we are in a thread conversation. - // 2. There are unread messages or we are in a thread conversation. - // 3. In a thread, the parent has at least one reply — the server-side - // thread object doesn't exist until the first reply lands. + // In a thread: the parent must have at least one reply (the server-side + // thread object doesn't exist until the first reply lands), and the + // channel must be up to date. // - // If any of the conditions are not met, the function returns early. - // Otherwise, it calls the _markMessagesAsRead function to mark the messages - // as read. + // In the channel, mirrors iOS's `shouldMarkChannelRead` gating: + // 1. The newest page is loaded (`isUpToDate`). + // 2. There is something unread to mark. + // 3. The bottom has been seen — either it's visible now, or it was + // visible earlier and the user has since scrolled away + // (`hasSeenLastMessage`). + // 4. If there's an active manual mark-unread (`isMarkedAsUnread`), the + // viewport must genuinely differ from the one snapshotted when it + // was first observed (`_markUnreadViewportSnapshot`) — otherwise the + // anchor being immediately "visible" again (it's usually the very + // message just marked, with nothing yet scrolled) would undo the + // user's action instantly. + // 5. Divider A's anchor has actually been seen or scrolled past + // (`hasSeenFirstUnreadMessage`) — trivially satisfied when there's + // nothing to see (the channel opened fully read) or for channels using + // local unread counts, mirroring iOS's escape hatch. + // + // A caller-supplied [StreamMessageListViewConfiguration.shouldMarkRead] + // overrides conditions 3-5. Future _maybeMarkMessagesAsRead() async { final channel = streamChannel?.channel; if (channel == null) return; final isInThread = widget.parentMessage != null; - // A server-side thread object only exists once the parent has at least - // one reply; markThreadRead on a reply-less parent returns 404. - if (isInThread && (widget.parentMessage?.replyCount ?? 0) == 0) return; + if (isInThread) { + // A server-side thread object only exists once the parent has at + // least one reply; markThreadRead on a reply-less parent returns 404. + if ((widget.parentMessage?.replyCount ?? 0) == 0) return; + if (!(channel.state?.isUpToDate ?? false)) return; + return _debouncedMarkMessagesAsRead(); + } final isUpToDate = channel.state?.isUpToDate ?? false; - if (!isInThread && !isUpToDate) return; + if (!isUpToDate) return; + + final unreadCount = channel.state?.unreadCount ?? 0; + if (unreadCount <= 0) return; + + final noPreexistingUnread = _unreadBaselineCaptured && _unreadBaseline == null; + // Equivalent to `channel.usesLocalUnreadCount`, spelled out via + // `channel.client` rather than `Channel`'s private client field so it + // stays evaluable against a test double that only implements the public + // API surface. + final usesLocalUnreadCount = channel.client.isLocalUnreadCountEnabled && !channel.canUseReadReceipts; + final hasSeenFirstUnreadMessage = noPreexistingUnread || _hasSeenFirstUnread.value || usesLocalUnreadCount; + final isMarkedAsUnread = channel.state?.isMarkedAsUnread ?? false; + final hasSeenLastMessage = _hasSeenLastMessage || !_showScrollToBottom.value; + + if (widget.config.shouldMarkRead case final shouldMarkRead?) { + final details = StreamMarkReadDetails( + hasSeenLastMessage: hasSeenLastMessage, + hasSeenFirstUnreadMessage: hasSeenFirstUnreadMessage, + isMarkedAsUnread: isMarkedAsUnread, + unreadCount: unreadCount, + ); + if (!shouldMarkRead(details)) return; - final hasUnread = (channel.state?.unreadCount ?? 0) > 0; - if (!isInThread && !hasUnread) return; + await _debouncedMarkMessagesAsRead(); + _hasSeenLastMessage = false; + _markUnreadViewportSnapshot = null; + _markUnreadViewportDiverged = false; + return; + } + + if (!hasSeenLastMessage) return; + if (!hasSeenFirstUnreadMessage) return; + + if (isMarkedAsUnread) { + // `_handleItemPositionsChanged` already keeps this up to date on + // every position-changed tick; this call only matters as a fallback + // if this is ever reached some other way. See + // `_markUnreadViewportSnapshot`'s doc comment for why the guard has + // to latch on divergence rather than checking `isMarkedAsUnread` + // directly as a persistent gate. + _checkMarkUnreadViewportDivergence(_itemPositionListener.itemPositions.value); + if (!_markUnreadViewportDiverged) return; + } - // Mark messages as read if it's allowed. - return _debouncedMarkMessagesAsRead(); + await _debouncedMarkMessagesAsRead(); + _hasSeenLastMessage = false; + _markUnreadViewportSnapshot = null; + _markUnreadViewportDiverged = false; } void _getOnThreadTap() { diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/mlv_utils.dart b/packages/stream_chat_flutter/lib/src/message_list_view/mlv_utils.dart index 5977cbefe5..c5212276ea 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view/mlv_utils.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view/mlv_utils.dart @@ -31,13 +31,16 @@ int getInitialIndex( if (targetMessageIndex != -1) return targetMessageIndex + 2; } - // Otherwise, return the first unread message index if available. - if (channelState.getFirstUnreadMessage() case final firstUnreadMessage?) { - final firstUnreadMessageIndex = messages.indexWhere( - (it) => it.id == firstUnreadMessage.id, - ); - - if (firstUnreadMessageIndex != -1) return firstUnreadMessageIndex + 2; + // Otherwise, return the first unread message index if available — unless + // the caller opted out via [StreamChannel.openAtFirstUnread]. + if (channelState.widget.openAtFirstUnread) { + if (channelState.getFirstUnreadMessage() case final firstUnreadMessage?) { + final firstUnreadMessageIndex = messages.indexWhere( + (it) => it.id == firstUnreadMessage.id, + ); + + if (firstUnreadMessageIndex != -1) return firstUnreadMessageIndex + 2; + } } return 0; diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/stream_message_list_view_configuration.dart b/packages/stream_chat_flutter/lib/src/message_list_view/stream_message_list_view_configuration.dart index 2cc256e646..263fd59bf0 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view/stream_message_list_view_configuration.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view/stream_message_list_view_configuration.dart @@ -1,5 +1,6 @@ import 'package:flutter/widgets.dart'; import 'package:stream_chat_flutter/src/message_list_view/auto_scroll_policy.dart'; +import 'package:stream_chat_flutter/src/message_list_view/mark_read_details.dart'; /// {@template streamMessageListConfiguration} /// Holds all behavior flags and non-theme, non-builder configuration for @@ -36,6 +37,7 @@ class StreamMessageListViewConfiguration { this.keyboardDismissBehavior = .onDrag, this.scrollPhysics = const ClampingScrollPhysics(), this.autoScrollPolicy = .whenOwnMessageOrAtBottom, + this.shouldMarkRead, }); /// Whether to mark the channel as read when the user scrolls to the bottom. @@ -43,6 +45,18 @@ class StreamMessageListViewConfiguration { /// Defaults to true. final bool markReadWhenAtTheBottom; + /// Overrides the built-in gating for automatic mark-read. + /// + /// When null (the default), the list marks the channel as read once the + /// bottom has been seen, the pre-existing unread boundary (if any) has + /// been seen or scrolled past, and there is no active manual mark-unread — + /// see [StreamMarkReadDetails]. Provide this to fully control the decision + /// instead. + /// + /// Only affects channel reads; has no effect on thread reads or on + /// [markReadWhenAtTheBottom] being `false`. + final StreamShouldMarkReadPredicate? shouldMarkRead; + /// Whether swiping a message triggers a quoted-reply action. /// /// Defaults to false. @@ -162,9 +176,11 @@ class StreamMessageListViewConfiguration { ScrollViewKeyboardDismissBehavior? keyboardDismissBehavior, ScrollPhysics? scrollPhysics, StreamAutoScrollPolicy? autoScrollPolicy, + StreamShouldMarkReadPredicate? shouldMarkRead, }) { return StreamMessageListViewConfiguration( markReadWhenAtTheBottom: markReadWhenAtTheBottom ?? this.markReadWhenAtTheBottom, + shouldMarkRead: shouldMarkRead ?? this.shouldMarkRead, swipeToReply: swipeToReply ?? this.swipeToReply, showScrollToBottom: showScrollToBottom ?? this.showScrollToBottom, showUnreadCountOnScrollToBottom: showUnreadCountOnScrollToBottom ?? this.showUnreadCountOnScrollToBottom, @@ -204,7 +220,8 @@ class StreamMessageListViewConfiguration { other.retentionTrimBuffer == retentionTrimBuffer && other.keyboardDismissBehavior == keyboardDismissBehavior && other.scrollPhysics == scrollPhysics && - other.autoScrollPolicy == autoScrollPolicy; + other.autoScrollPolicy == autoScrollPolicy && + other.shouldMarkRead == shouldMarkRead; } @override @@ -226,5 +243,6 @@ class StreamMessageListViewConfiguration { keyboardDismissBehavior, scrollPhysics, autoScrollPolicy, + shouldMarkRead, ); } diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/unread_indicator_button.dart b/packages/stream_chat_flutter/lib/src/message_list_view/unread_indicator_button.dart index 0608d19185..20727aeccc 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view/unread_indicator_button.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view/unread_indicator_button.dart @@ -1,15 +1,15 @@ import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; import 'package:stream_chat_flutter/src/utils/extensions.dart'; -import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; import 'package:stream_core_flutter/chat.dart' as core; /// {@template unreadIndicatorButton} -/// A button that displays the number of unread messages in a channel. +/// A floating "jump to unread" pill showing a fixed unread count. /// -/// [UnreadIndicatorButton] listens to the current user's read state and shows -/// a jump-to-unread button when there are unread messages. Users can tap to -/// navigate to the oldest unread message or dismiss the indicator. +/// [UnreadIndicatorButton] is purely presentational: the host +/// [StreamMessageListView] decides when it should be visible (only while the +/// pre-existing unread boundary sits above the viewport) and supplies the +/// frozen [unreadCount]. Users can tap to navigate to the first unread +/// message or dismiss the indicator. /// /// {@tool snippet} /// @@ -17,8 +17,9 @@ import 'package:stream_core_flutter/chat.dart' as core; /// /// ```dart /// UnreadIndicatorButton( -/// onJumpTap: (lastReadMessageId) async { -/// // scroll to the unread message +/// unreadCount: 5, +/// onJumpTap: () async { +/// // scroll to the first unread message /// }, /// onDismissTap: () async { /// // mark channel as read @@ -29,21 +30,27 @@ import 'package:stream_core_flutter/chat.dart' as core; /// /// See also: /// -/// * [StreamMessageListView], which hosts this widget. +/// * [StreamMessageListView], which hosts this widget and owns its +/// visibility. /// {@endtemplate} class UnreadIndicatorButton extends StatelessWidget { /// Creates an unread indicator button. const UnreadIndicatorButton({ super.key, + required this.unreadCount, required this.onJumpTap, required this.onDismissTap, }); - /// Called when the jump-to-unread area is tapped. + /// The fixed unread count to display. /// - /// Receives the ID of the last message the current user has read, - /// which can be used to scroll to that position. - final Future Function(String? lastReadMessageId) onJumpTap; + /// This is the pre-existing unread boundary's count, captured when the + /// channel was opened — it does not change for the lifetime of the + /// session. + final int unreadCount; + + /// Called when the jump-to-unread area is tapped. + final Future Function() onJumpTap; /// Called when the dismiss button is tapped. /// @@ -52,22 +59,10 @@ class UnreadIndicatorButton extends StatelessWidget { @override Widget build(BuildContext context) { - final channel = StreamChannel.of(context).channel; - if (channel.state == null) return const Empty(); - - return BetterStreamBuilder( - initialData: channel.state!.currentUserRead, - stream: channel.state!.currentUserReadStream, - builder: (context, currentUserRead) { - final unreadCount = currentUserRead.unreadMessages; - if (unreadCount <= 0) return const Empty(); - - return core.StreamJumpToUnreadButton( - label: context.translations.unreadCountIndicatorLabel(unreadCount: unreadCount), - onJumpPressed: () => onJumpTap(currentUserRead.lastReadMessageId), - onDismissPressed: onDismissTap, - ); - }, + return core.StreamJumpToUnreadButton( + label: context.translations.unreadCountIndicatorLabel(unreadCount: unreadCount), + onJumpPressed: onJumpTap, + onDismissPressed: onDismissTap, ); } } diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/unread_messages_separator.dart b/packages/stream_chat_flutter/lib/src/message_list_view/unread_messages_separator.dart index dac08c0cfe..e2480b35a5 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view/unread_messages_separator.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view/unread_messages_separator.dart @@ -6,8 +6,8 @@ import 'package:stream_core_flutter/chat.dart' as core; /// A full-width banner that marks the boundary between read and unread /// messages in a [StreamMessageListView]. /// -/// [UnreadMessagesSeparator] displays a localised "Unread Messages" label -/// inside a subtle container with top and bottom borders. +/// [UnreadMessagesSeparator] displays a localised "{count} unread messages" +/// label inside a subtle container with top and bottom borders. /// /// {@tool snippet} /// @@ -106,7 +106,7 @@ class UnreadMessagesSeparator extends StatelessWidget { child: Padding( padding: effectiveContentPadding, child: Text( - context.translations.unreadMessagesSeparatorText(), + context.translations.unreadMessagesSeparatorLabel(count: unreadCount), textAlign: TextAlign.center, style: effectiveTextStyle, ), diff --git a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart index 6a1c377bcd..be5848144f 100644 --- a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart +++ b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart @@ -86,10 +86,12 @@ export 'src/message_input/stream_message_composer.dart'; export 'src/message_input/stream_message_composer_attachment_list.dart'; export 'src/message_input/stream_message_text_field.dart'; export 'src/message_list_view/auto_scroll_policy.dart'; +export 'src/message_list_view/mark_read_details.dart'; export 'src/message_list_view/message_list_view.dart'; export 'src/message_list_view/stream_message_list_view_builders.dart'; export 'src/message_list_view/stream_message_list_view_configuration.dart'; export 'src/message_list_view/unread_indicator_button.dart'; +export 'src/message_list_view/unread_messages_separator.dart'; export 'src/message_modal/message_action_confirmation_modal.dart'; export 'src/message_modal/message_actions_modal.dart'; export 'src/message_modal/message_modal.dart'; diff --git a/packages/stream_chat_flutter/test/src/localization/default_translations_test.dart b/packages/stream_chat_flutter/test/src/localization/default_translations_test.dart index 0950c6832e..22c90669fd 100644 --- a/packages/stream_chat_flutter/test/src/localization/default_translations_test.dart +++ b/packages/stream_chat_flutter/test/src/localization/default_translations_test.dart @@ -183,6 +183,8 @@ void main() { expect(translations.replyToMessageLabel, isNotNull); expect(translations.unreadCountIndicatorLabel(unreadCount: 2), isNotNull); expect(translations.unreadMessagesSeparatorText(), isNotNull); + expect(translations.unreadMessagesSeparatorLabel(count: 1), '1 unread message'); + expect(translations.unreadMessagesSeparatorLabel(count: 2), '2 unread messages'); expect(translations.markUnreadError, isNotNull); expect(translations.markAsUnreadLabel, isNotNull); expect(translations.toggleBlockUnblockUserText(isBlocked: false), isNotNull); diff --git a/packages/stream_chat_flutter/test/src/message_list_view/mark_read_test.dart b/packages/stream_chat_flutter/test/src/message_list_view/mark_read_test.dart index 3d2191e90f..27efe5dda3 100644 --- a/packages/stream_chat_flutter/test/src/message_list_view/mark_read_test.dart +++ b/packages/stream_chat_flutter/test/src/message_list_view/mark_read_test.dart @@ -1,16 +1,22 @@ // Tests for `StreamMessageListView`'s mark-read-at-the-bottom behavior. // -// The logic lives in `_handleLastItemFullyVisible` → -// `_maybeMarkMessagesAsRead`. It fires `channel.markRead()` when the user -// reaches the bottom of the list, gated on: +// The logic lives in `_handleItemPositionsChanged` → +// `_maybeMarkMessagesAsRead` (FLU-640). Marking the channel read requires all +// of: // // 1. `markReadWhenAtTheBottom` is true (the default). // 2. `channel.state.isUpToDate` is true (or we're in a thread). // 3. `channel.state.unreadCount > 0`. +// 4. The bottom has been seen (now, or earlier then scrolled away). +// 5. The pre-existing unread boundary (if any) has been seen or scrolled +// past — trivially satisfied when the channel opened fully read. +// 6. There is no active manual mark-unread (`channel.state.isMarkedAsUnread`). // -// In a thread, it fires `channel.markThreadRead(parentId)` instead, and is -// additionally gated on the parent having at least one reply — the server-side -// thread object only exists after the first reply, so an earlier call 404s. +// `StreamMessageListViewConfiguration.shouldMarkRead` can override 4-6. +// +// In a thread, it fires `channel.markThreadRead(parentId)` instead, gated +// only on the parent having at least one reply and the channel being up to +// date — conditions 4-6 don't apply there. // // These tests pin the expected behavior so regressions in the underlying // position-listener flow (SPL `itemPositions`, scroll wiring, etc.) surface @@ -32,11 +38,13 @@ void main() { late Channel channel; late ChannelClientState channelClientState; late ClientState clientState; + late OwnUser ownUser; late StreamController isUpToDateController; late StreamController unreadCountController; late StreamController> messagesController; late StreamController>> threadsController; + late StreamController currentUserReadController; setUpAll(() { registerFallbackValue(EventType.messageNew); @@ -46,23 +54,26 @@ void main() { client = MockClient(); clientState = MockClientState(); when(() => client.state).thenAnswer((_) => clientState); - final own = OwnUser(id: 'ownid'); - when(() => clientState.currentUser).thenReturn(own); - when(() => clientState.currentUserStream).thenAnswer((_) => Stream.value(own)); + ownUser = OwnUser(id: 'ownid'); + when(() => clientState.currentUser).thenReturn(ownUser); + when(() => clientState.currentUserStream).thenAnswer((_) => Stream.value(ownUser)); channel = MockChannel(); channelClientState = MockChannelState(); when(() => channel.client).thenReturn(client); when(() => channel.state).thenReturn(channelClientState); + when(() => client.isLocalUnreadCountEnabled).thenReturn(false); isUpToDateController = StreamController.broadcast(); unreadCountController = StreamController.broadcast(); messagesController = StreamController>.broadcast(); threadsController = StreamController>>.broadcast(); + currentUserReadController = StreamController.broadcast(); addTearDown(isUpToDateController.close); addTearDown(unreadCountController.close); addTearDown(messagesController.close); addTearDown(threadsController.close); + addTearDown(currentUserReadController.close); when(() => channelClientState.threadsStream).thenAnswer((_) => threadsController.stream); when(() => channelClientState.threads).thenReturn(const {}); @@ -72,9 +83,9 @@ void main() { when(() => channelClientState.read).thenReturn([]); when(() => channelClientState.membersStream).thenAnswer((_) => const Stream.empty()); when(() => channelClientState.members).thenReturn([]); - when(() => channelClientState.currentUserRead).thenReturn(null); - when(() => channelClientState.currentUserReadStream).thenAnswer((_) => const Stream.empty()); + when(() => channelClientState.currentUserReadStream).thenAnswer((_) => currentUserReadController.stream); when(() => channelClientState.messagesStream).thenAnswer((_) => messagesController.stream); + when(() => channelClientState.isMarkedAsUnread).thenReturn(false); // Mark-read mocks return immediately. when(() => channel.markRead(messageId: any(named: 'messageId'))).thenAnswer((_) async => EmptyResponse()); @@ -89,6 +100,12 @@ void main() { ).thenAnswer((_) async => QueryRepliesResponse()..messages = []); }); + // Default: opened with nothing pre-existing unread, so the FLU-640 + // "has seen the first unread boundary" condition is trivially satisfied + // and doesn't gate these tests unless a `currentUserRead` override says + // otherwise. + Read noPreexistingUnreadRead() => Read(user: ownUser, lastRead: DateTime.now(), unreadMessages: 0); + Future pumpMessageList( WidgetTester tester, { required List messages, @@ -96,11 +113,17 @@ void main() { required int unreadCount, bool markReadWhenAtTheBottom = true, Message? parentMessage, + Read? currentUserRead, + bool openAtFirstUnread = false, + StreamShouldMarkReadPredicate? shouldMarkRead, }) async { when(() => channelClientState.isUpToDate).thenReturn(isUpToDate); when(() => channelClientState.unreadCount).thenReturn(unreadCount); when(() => channelClientState.messages).thenReturn(messages); + final resolvedRead = currentUserRead ?? noPreexistingUnreadRead(); + when(() => channelClientState.currentUserRead).thenReturn(resolvedRead); + // In thread mode, MessageListCore reads from state.threads[parentId] and // subscribes to state.threadsStream. Seed both so the reply list renders. if (parentMessage != null) { @@ -117,10 +140,12 @@ void main() { themeData: StreamChatThemeData(), child: StreamChannel( channel: channel, + openAtFirstUnread: openAtFirstUnread, child: StreamMessageListView( parentMessage: parentMessage, config: StreamMessageListViewConfiguration( markReadWhenAtTheBottom: markReadWhenAtTheBottom, + shouldMarkRead: shouldMarkRead, ), ), ), @@ -131,6 +156,7 @@ void main() { // Prime the streams. isUpToDateController.add(isUpToDate); unreadCountController.add(unreadCount); + currentUserReadController.add(resolvedRead); if (parentMessage != null) { threadsController.add({parentMessage.id: messages}); } else { @@ -146,7 +172,7 @@ void main() { 'unreadCount>0', (tester) async { final other = User(id: 'otherid'); - final messages = generateConversation(20, users: [other]); + final messages = generateConversation(20, users: [other]).reversed.toList(); await pumpMessageList( tester, @@ -163,7 +189,7 @@ void main() { 'does NOT fire when isUpToDate=false (gate on incomplete state)', (tester) async { final other = User(id: 'otherid'); - final messages = generateConversation(20, users: [other]); + final messages = generateConversation(20, users: [other]).reversed.toList(); await pumpMessageList( tester, @@ -182,7 +208,7 @@ void main() { 'does NOT fire when unreadCount is 0', (tester) async { final other = User(id: 'otherid'); - final messages = generateConversation(20, users: [other]); + final messages = generateConversation(20, users: [other]).reversed.toList(); await pumpMessageList( tester, @@ -201,7 +227,7 @@ void main() { 'does NOT fire when markReadWhenAtTheBottom is false', (tester) async { final other = User(id: 'otherid'); - final messages = generateConversation(20, users: [other]); + final messages = generateConversation(20, users: [other]).reversed.toList(); await pumpMessageList( tester, @@ -216,6 +242,132 @@ void main() { ); }, ); + + testWidgets( + 'does NOT fire when opened at the bottom with an unseen pre-existing ' + 'unread boundary (FLU-640)', + (tester) async { + final other = User(id: 'otherid'); + final messages = generateConversation(20, users: [other]).reversed.toList(); + // A boundary partway through the list — the user hasn't scrolled up + // to see it since the list opens at the bottom. + final lastReadMessageId = messages[10].id; + + await pumpMessageList( + tester, + messages: messages, + isUpToDate: true, + unreadCount: 5, + openAtFirstUnread: false, + currentUserRead: Read( + user: ownUser, + lastRead: DateTime.now(), + unreadMessages: 5, + lastReadMessageId: lastReadMessageId, + ), + ); + + verifyNever(() => channel.markRead(messageId: any(named: 'messageId'))); + }, + ); + + testWidgets( + 'does NOT fire when the channel has an active manual mark-unread', + (tester) async { + final other = User(id: 'otherid'); + final messages = generateConversation(20, users: [other]).reversed.toList(); + when(() => channelClientState.isMarkedAsUnread).thenReturn(true); + + await pumpMessageList( + tester, + messages: messages, + isUpToDate: true, + unreadCount: 5, + ); + + verifyNever(() => channel.markRead(messageId: any(named: 'messageId'))); + }, + ); + + testWidgets( + 'fires once the viewport genuinely changes after mounting with an ' + 'already-active manual mark-unread (no live transition for ' + '_handleCurrentUserReadChanged to hook the snapshot on)', + (tester) async { + final other = User(id: 'otherid'); + final messages = generateConversation(40, users: [other]).reversed.toList(); + when(() => channelClientState.isMarkedAsUnread).thenReturn(true); + + await pumpMessageList( + tester, + messages: messages, + isUpToDate: true, + unreadCount: 5, + ); + + // The very first layout — at the bottom, nothing scrolled yet — is + // exactly the moment the viewport snapshot gets captured. Marking + // read here would be the reintroduced deadlock: a channel that + // opens already marked unread and happens to land at the bottom + // would get instantly marked read again before the user did + // anything. + verifyNever(() => channel.markRead(messageId: any(named: 'messageId'))); + + // A genuine scroll away and back changes the viewport, proving the + // user did something since the snapshot was taken — this should + // no longer be blocked. + await tester.drag(find.byType(StreamMessageListView), const Offset(0, 400)); + await tester.pumpAndSettle(); + await tester.drag(find.byType(StreamMessageListView), const Offset(0, -1000)); + await tester.pumpAndSettle(); + + verify(() => channel.markRead(messageId: any(named: 'messageId'))).called(1); + }, + ); + + testWidgets( + 'a shouldMarkRead override that returns false blocks an otherwise-allowed mark-read', + (tester) async { + final other = User(id: 'otherid'); + final messages = generateConversation(20, users: [other]).reversed.toList(); + + await pumpMessageList( + tester, + messages: messages, + isUpToDate: true, + unreadCount: 5, + shouldMarkRead: (details) => false, + ); + + verifyNever(() => channel.markRead(messageId: any(named: 'messageId'))); + }, + ); + + testWidgets( + 'a shouldMarkRead override that returns true allows a mark-read the default gating would block', + (tester) async { + final other = User(id: 'otherid'); + final messages = generateConversation(20, users: [other]).reversed.toList(); + final lastReadMessageId = messages[10].id; + + await pumpMessageList( + tester, + messages: messages, + isUpToDate: true, + unreadCount: 5, + openAtFirstUnread: false, + currentUserRead: Read( + user: ownUser, + lastRead: DateTime.now(), + unreadMessages: 5, + lastReadMessageId: lastReadMessageId, + ), + shouldMarkRead: (details) => true, + ); + + verify(() => channel.markRead(messageId: any(named: 'messageId'))).called(1); + }, + ); }); group('thread markThreadRead gates', () { @@ -310,7 +462,7 @@ void main() { 'is hidden when the user lands at the bottom with isUpToDate=true', (tester) async { final other = User(id: 'otherid'); - final messages = generateConversation(20, users: [other]); + final messages = generateConversation(20, users: [other]).reversed.toList(); await pumpMessageList( tester, @@ -319,8 +471,134 @@ void main() { unreadCount: 0, ); - // The default scroll-to-bottom button is a FloatingActionButton. - expect(find.byType(FloatingActionButton), findsNothing); + // The default scroll-to-bottom button is a floating StreamButton, + // shown only while scrolled away from the bottom. + expect(find.byType(StreamButton), findsNothing); + }, + ); + }); + + group('unread pill', () { + testWidgets( + 'shown when opened at the bottom with an unseen pre-existing unread boundary', + (tester) async { + final other = User(id: 'otherid'); + final messages = generateConversation(20, users: [other]).reversed.toList(); + final lastReadMessageId = messages[10].id; + + await pumpMessageList( + tester, + messages: messages, + isUpToDate: true, + unreadCount: 5, + openAtFirstUnread: false, + currentUserRead: Read( + user: ownUser, + lastRead: DateTime.now(), + unreadMessages: 5, + lastReadMessageId: lastReadMessageId, + ), + ); + + expect(find.byType(UnreadIndicatorButton), findsOneWidget); + }, + ); + + testWidgets( + 'absent when the channel opened with nothing pre-existing unread', + (tester) async { + final other = User(id: 'otherid'); + final messages = generateConversation(20, users: [other]).reversed.toList(); + + await pumpMessageList( + tester, + messages: messages, + isUpToDate: true, + unreadCount: 0, + openAtFirstUnread: false, + ); + + expect(find.byType(UnreadIndicatorButton), findsNothing); + }, + ); + + testWidgets( + 'is shown immediately even when the boundary message has not loaded yet ' + '(top pagination pending)', + (tester) async { + final other = User(id: 'otherid'); + final messages = generateConversation(20, users: [other]).reversed.toList(); + // Not part of the loaded window — simulates the read boundary sitting + // further back in history than top pagination has reached yet. The + // pill's count is known from the `Read` itself, so it shouldn't have + // to wait on the anchor message to load before appearing. + const lastReadMessageId = 'not-yet-loaded-message-id'; + + await pumpMessageList( + tester, + messages: messages, + isUpToDate: true, + unreadCount: 5, + openAtFirstUnread: false, + currentUserRead: Read( + user: ownUser, + lastRead: DateTime.now(), + unreadMessages: 5, + lastReadMessageId: lastReadMessageId, + ), + ); + + final indicator = tester.widget( + find.byType(UnreadIndicatorButton), + ); + expect(indicator.unreadCount, 5); + }, + ); + + testWidgets( + "tapping jump before the anchor resolves falls back to the boundary's " + 'lastReadMessageId instead of doing nothing', + (tester) async { + final other = User(id: 'otherid'); + final messages = generateConversation(20, users: [other]).reversed.toList(); + const lastReadMessageId = 'not-yet-loaded-message-id'; + + when( + () => channel.query( + preferOffline: any(named: 'preferOffline'), + messagesPagination: any(named: 'messagesPagination'), + ), + ).thenAnswer((_) async => const ChannelState(messages: [])); + + await pumpMessageList( + tester, + messages: messages, + isUpToDate: true, + unreadCount: 5, + openAtFirstUnread: false, + currentUserRead: Read( + user: ownUser, + lastRead: DateTime.now(), + unreadMessages: 5, + lastReadMessageId: lastReadMessageId, + ), + ); + + final indicator = tester.widget( + find.byType(UnreadIndicatorButton), + ); + // Not awaited directly: `_scrollToMessage`'s fallback awaits + // `WidgetsBinding.instance.endOfFrame` after the query, which only + // resolves once the test binding actually pumps a frame. + unawaited(indicator.onJumpTap()); + await tester.pumpAndSettle(); + + verify( + () => channel.query( + preferOffline: false, + messagesPagination: const PaginationParams(limit: 30, idAround: lastReadMessageId), + ), + ).called(1); }, ); }); @@ -330,7 +608,8 @@ void main() { 'marks the channel read immediately when tapped', (tester) async { final other = User(id: 'otherid'); - final messages = generateConversation(20, users: [other]); + final messages = generateConversation(20, users: [other]).reversed.toList(); + final lastReadMessageId = messages[10].id; await pumpMessageList( tester, @@ -338,6 +617,13 @@ void main() { isUpToDate: true, unreadCount: 5, markReadWhenAtTheBottom: false, + openAtFirstUnread: false, + currentUserRead: Read( + user: ownUser, + lastRead: DateTime.now(), + unreadMessages: 5, + lastReadMessageId: lastReadMessageId, + ), ); // Nothing has marked the channel read yet. @@ -356,7 +642,8 @@ void main() { 'fires markRead on every tap (not debounced)', (tester) async { final other = User(id: 'otherid'); - final messages = generateConversation(20, users: [other]); + final messages = generateConversation(20, users: [other]).reversed.toList(); + final lastReadMessageId = messages[10].id; await pumpMessageList( tester, @@ -364,6 +651,13 @@ void main() { isUpToDate: true, unreadCount: 5, markReadWhenAtTheBottom: false, + openAtFirstUnread: false, + currentUserRead: Read( + user: ownUser, + lastRead: DateTime.now(), + unreadMessages: 5, + lastReadMessageId: lastReadMessageId, + ), ); final indicator = tester.widget( @@ -380,5 +674,37 @@ void main() { verify(() => channel.markRead(messageId: any(named: 'messageId'))).called(3); }, ); + + testWidgets( + 'dismisses the pill permanently, even though markReadWhenAtTheBottom is off', + (tester) async { + final other = User(id: 'otherid'); + final messages = generateConversation(20, users: [other]).reversed.toList(); + final lastReadMessageId = messages[10].id; + + await pumpMessageList( + tester, + messages: messages, + isUpToDate: true, + unreadCount: 5, + markReadWhenAtTheBottom: false, + openAtFirstUnread: false, + currentUserRead: Read( + user: ownUser, + lastRead: DateTime.now(), + unreadMessages: 5, + lastReadMessageId: lastReadMessageId, + ), + ); + + final indicator = tester.widget( + find.byType(UnreadIndicatorButton), + ); + await indicator.onDismissTap(); + await tester.pumpAndSettle(); + + expect(find.byType(UnreadIndicatorButton), findsNothing); + }, + ); }); } diff --git a/packages/stream_chat_flutter/test/src/message_list_view/unread_divider_test.dart b/packages/stream_chat_flutter/test/src/message_list_view/unread_divider_test.dart new file mode 100644 index 0000000000..e431d94d8f --- /dev/null +++ b/packages/stream_chat_flutter/test/src/message_list_view/unread_divider_test.dart @@ -0,0 +1,429 @@ +// Tests for the unread-messages divider, the jump-to-unread pill, and the +// scroll-to-bottom badge (FLU-649 / FLU-650). +// +// - The unread divider ("{n} unread messages"): anchored to the +// pre-existing unread boundary captured when the channel opens. The +// anchor is frozen — it stays on screen for the whole session regardless +// of scrolling or reads — but its displayed count keeps counting up as +// further messages arrive out of view during the session, mirroring +// WhatsApp, rather than staying frozen at the open-time count. +// - The pill shows the count of unread messages captured when the channel +// was opened — this one *does* stay frozen — and is gated on that +// boundary being above the viewport. +// - The scroll-to-bottom badge counts messages that arrive while the user +// is scrolled away from the bottom, and always resets to 0 once they +// reach the bottom. + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import '../../test_utils/data_generator.dart'; +import '../mocks.dart'; + +void main() { + late StreamChatClient client; + late Channel channel; + late ChannelClientState channelClientState; + late ClientState clientState; + late OwnUser ownUser; + + late StreamController isUpToDateController; + late StreamController unreadCountController; + late StreamController> messagesController; + late StreamController messageNewController; + late StreamController currentUserReadController; + + setUpAll(() { + registerFallbackValue(EventType.messageNew); + }); + + setUp(() { + client = MockClient(); + clientState = MockClientState(); + when(() => client.state).thenAnswer((_) => clientState); + ownUser = OwnUser(id: 'ownid'); + when(() => clientState.currentUser).thenReturn(ownUser); + when(() => clientState.currentUserStream).thenAnswer((_) => Stream.value(ownUser)); + when(() => client.isLocalUnreadCountEnabled).thenReturn(false); + + isUpToDateController = StreamController.broadcast(); + unreadCountController = StreamController.broadcast(); + messagesController = StreamController>.broadcast(); + // MockChannel.on filters this by event.type, so events pushed here + // surface to channel.on(EventType.messageNew) subscribers. + messageNewController = StreamController.broadcast(); + currentUserReadController = StreamController.broadcast(); + addTearDown(isUpToDateController.close); + addTearDown(unreadCountController.close); + addTearDown(messagesController.close); + addTearDown(messageNewController.close); + addTearDown(currentUserReadController.close); + + channel = MockChannel(eventStream: messageNewController.stream); + channelClientState = MockChannelState(); + when(() => channel.client).thenReturn(client); + when(() => channel.state).thenReturn(channelClientState); + + when(() => channelClientState.threadsStream).thenAnswer((_) => const Stream.empty()); + when(() => channelClientState.isUpToDateStream).thenAnswer((_) => isUpToDateController.stream); + when(() => channelClientState.unreadCountStream).thenAnswer((_) => unreadCountController.stream); + when(() => channelClientState.readStream).thenAnswer((_) => const Stream.empty()); + when(() => channelClientState.read).thenReturn([]); + when(() => channelClientState.membersStream).thenAnswer((_) => const Stream.empty()); + when(() => channelClientState.members).thenReturn([]); + when(() => channelClientState.currentUserReadStream).thenAnswer((_) => currentUserReadController.stream); + when(() => channelClientState.messagesStream).thenAnswer((_) => messagesController.stream); + when(() => channelClientState.isMarkedAsUnread).thenReturn(false); + + when(() => channel.markRead(messageId: any(named: 'messageId'))).thenAnswer((_) async => EmptyResponse()); + }); + + Future pumpMessageList( + WidgetTester tester, { + required List messages, + bool isUpToDate = true, + required int unreadCount, + required Read currentUserRead, + bool openAtFirstUnread = false, + }) async { + when(() => channelClientState.isUpToDate).thenReturn(isUpToDate); + when(() => channelClientState.unreadCount).thenReturn(unreadCount); + when(() => channelClientState.messages).thenReturn(messages); + when(() => channelClientState.currentUserRead).thenReturn(currentUserRead); + + await tester.runAsync(() async { + await tester.pumpWidget( + MaterialApp( + home: DefaultAssetBundle( + bundle: rootBundle, + child: StreamChat( + client: client, + themeData: StreamChatThemeData(), + child: StreamChannel( + channel: channel, + openAtFirstUnread: openAtFirstUnread, + child: const StreamMessageListView( + config: StreamMessageListViewConfiguration( + markReadWhenAtTheBottom: false, + // Own messages otherwise auto-scroll back to the + // bottom by default, which would confound these + // tests' control over scroll position. + autoScrollPolicy: StreamAutoScrollPolicy.disabled, + ), + ), + ), + ), + ), + ), + ); + isUpToDateController.add(isUpToDate); + unreadCountController.add(unreadCount); + currentUserReadController.add(currentUserRead); + messagesController.add(messages); + await tester.pumpAndSettle(); + }); + } + + // Appends to the end because production state.messages is oldest-first. + Future deliverMessageNew( + WidgetTester tester, { + required Message newMessage, + required List existing, + }) async { + final updated = [...existing, newMessage]; + when(() => channelClientState.messages).thenReturn(updated); + await tester.runAsync(() async { + messagesController.add(updated); + messageNewController.add(Event(type: EventType.messageNew, message: newMessage, cid: channel.cid)); + await tester.pumpAndSettle(); + }); + } + + group('unread divider (pre-existing unread)', () { + testWidgets( + 'shows the open-time count and stays visible after unreadCount drops to 0', + (tester) async { + final other = User(id: 'otherid'); + final messages = generateConversation(20, users: [other]).reversed.toList(); + // Close to the bottom so the anchor message — and its divider — are + // guaranteed to be within the initially-rendered window regardless + // of viewport size; the list opens at the bottom (openAtFirstUnread + // is false in this helper) and SPL only builds visible items. + final lastReadMessageId = messages[messages.length - 3].id; + + await pumpMessageList( + tester, + messages: messages, + unreadCount: 2, + currentUserRead: Read( + user: ownUser, + lastRead: DateTime.now(), + unreadMessages: 2, + lastReadMessageId: lastReadMessageId, + ), + ); + + expect(find.text('2 unread messages'), findsOneWidget); + + // Simulate an auto mark-read completing server-side: the live count + // drops to 0, but the divider must not react to it — its anchor and + // open-time count are frozen. + unreadCountController.add(0); + when(() => channelClientState.unreadCount).thenReturn(0); + await tester.pumpAndSettle(); + + expect(find.text('2 unread messages'), findsOneWidget); + }, + ); + + testWidgets( + 'is absent when the channel opened with nothing pre-existing unread', + (tester) async { + final other = User(id: 'otherid'); + final messages = generateConversation(20, users: [other]).reversed.toList(); + + await pumpMessageList( + tester, + messages: messages, + unreadCount: 0, + currentUserRead: Read(user: ownUser, lastRead: DateTime.now(), unreadMessages: 0), + ); + + expect(find.textContaining('unread message'), findsNothing); + }, + ); + + testWidgets( + 'keeps counting up as further messages arrive out of view', + (tester) async { + final other = User(id: 'otherid'); + final messages = generateConversation(20, users: [other]).reversed.toList(); + final lastReadMessageId = messages[messages.length - 3].id; + + await pumpMessageList( + tester, + messages: messages, + unreadCount: 2, + currentUserRead: Read( + user: ownUser, + lastRead: DateTime.now(), + unreadMessages: 2, + lastReadMessageId: lastReadMessageId, + ), + ); + + expect(find.text('2 unread messages'), findsOneWidget); + + // Scroll just enough away from the bottom (to flip `isAtBottom`) + // while keeping the anchor, close to the bottom, within SPL's + // rendered window — an out-of-view arrival should then grow the + // divider's count on top of the open-time baseline. + await tester.drag(find.byType(StreamMessageListView), const Offset(0, 120)); + await tester.pumpAndSettle(); + + final fromOther = Message( + id: 'new-from-other-growth-probe', + text: 'Out of view', + user: other, + createdAt: DateTime.now(), + ); + await deliverMessageNew(tester, newMessage: fromOther, existing: messages); + + expect(find.text('3 unread messages'), findsOneWidget); + + // A second out-of-view arrival grows it further. + final secondFromOther = Message( + id: 'second-new-from-other-growth-probe', + text: 'Also out of view', + user: other, + createdAt: DateTime.now(), + ); + await deliverMessageNew(tester, newMessage: secondFromOther, existing: [...messages, fromOther]); + + expect(find.text('4 unread messages'), findsOneWidget); + }, + ); + + testWidgets( + 'also grows for arrivals seen live at the bottom, unlike the badge', + (tester) async { + final other = User(id: 'otherid'); + final messages = generateConversation(20, users: [other]).reversed.toList(); + final lastReadMessageId = messages[messages.length - 3].id; + + await pumpMessageList( + tester, + messages: messages, + unreadCount: 2, + currentUserRead: Read( + user: ownUser, + lastRead: DateTime.now(), + unreadMessages: 2, + lastReadMessageId: lastReadMessageId, + ), + ); + + expect(find.text('2 unread messages'), findsOneWidget); + + // Still at the bottom — no scrolling away — an arrival here would + // never bump the scroll-to-bottom badge, but the divider isn't + // "caught up" the way the badge's out-of-view count is; it should + // keep counting every arrival regardless of scroll position. + final fromOther = Message( + id: 'new-from-other-at-bottom-probe', + text: 'Seen immediately', + user: other, + createdAt: DateTime.now(), + ); + await deliverMessageNew(tester, newMessage: fromOther, existing: messages); + + expect(find.text('3 unread messages'), findsOneWidget); + }, + ); + }); + + // The badge is a floating overlay, always built regardless of scroll + // position — unlike the inline divider, which only exists in the widget + // tree once SPL actually renders its anchor message. + String? badgeLabel(WidgetTester tester) { + final finder = find.byType(StreamBadgeNotification); + if (finder.evaluate().isEmpty) return null; + return tester.widget(finder).props.label; + } + + group('scroll-to-bottom badge', () { + testWidgets( + 'appears only once the user is scrolled away from the bottom, and skips own messages', + (tester) async { + final other = User(id: 'otherid'); + final messages = generateConversation(40, users: [other]).reversed.toList(); + + await pumpMessageList( + tester, + messages: messages, + unreadCount: 0, + currentUserRead: Read(user: ownUser, lastRead: DateTime.now(), unreadMessages: 0), + ); + + // At the bottom: an arrival is in view, so it shouldn't bump the + // badge. + final whileAtBottom = Message( + id: 'while-at-bottom', + text: 'Seen immediately', + user: other, + createdAt: DateTime.now(), + ); + await deliverMessageNew(tester, newMessage: whileAtBottom, existing: messages); + + expect(badgeLabel(tester), isNull); + + // Scroll away from the bottom, then a message from another user + // should bump the badge. + await tester.drag(find.byType(StreamMessageListView), const Offset(0, 400)); + await tester.pumpAndSettle(); + + final fromOther = Message( + id: 'new-from-other', + text: 'Out of view', + user: other, + createdAt: DateTime.now(), + ); + await deliverMessageNew(tester, newMessage: fromOther, existing: [...messages, whileAtBottom]); + + expect(badgeLabel(tester), '1'); + + // A second out-of-view arrival grows the count. + final secondFromOther = Message( + id: 'second-new-from-other', + text: 'Also out of view', + user: other, + createdAt: DateTime.now(), + ); + await deliverMessageNew(tester, newMessage: secondFromOther, existing: [...messages, whileAtBottom, fromOther]); + + expect(badgeLabel(tester), '2'); + }, + ); + + testWidgets( + "the current user's own messages don't count towards the badge", + (tester) async { + final other = User(id: 'otherid'); + final messages = generateConversation(40, users: [other]).reversed.toList(); + + await pumpMessageList( + tester, + messages: messages, + unreadCount: 0, + currentUserRead: Read(user: ownUser, lastRead: DateTime.now(), unreadMessages: 0), + ); + + await tester.drag(find.byType(StreamMessageListView), const Offset(0, 400)); + await tester.pumpAndSettle(); + + // Auto-scroll is disabled in this helper's config, so an own + // message while scrolled up genuinely stays out of view too — this + // isolates "does it count" from "does it pull me back to the + // bottom" (a separate, already-covered concern in auto_scroll_test). + final ownMessage = Message( + id: 'own-while-scrolled-up', + text: 'My own message', + user: ownUser, + createdAt: DateTime.now(), + ); + await deliverMessageNew(tester, newMessage: ownMessage, existing: messages); + + expect(badgeLabel(tester), isNull); + }, + ); + + testWidgets( + 'always resets to 0 once the user reaches the bottom', + (tester) async { + final other = User(id: 'otherid'); + final messages = generateConversation(40, users: [other]).reversed.toList(); + + await pumpMessageList( + tester, + messages: messages, + unreadCount: 0, + currentUserRead: Read(user: ownUser, lastRead: DateTime.now(), unreadMessages: 0), + ); + + await tester.drag(find.byType(StreamMessageListView), const Offset(0, 400)); + await tester.pumpAndSettle(); + + final fromOther = Message( + id: 'new-from-other-reset-probe', + text: 'Out of view', + user: other, + createdAt: DateTime.now(), + ); + await deliverMessageNew(tester, newMessage: fromOther, existing: messages); + + expect(badgeLabel(tester), '1'); + + // Scroll back down to the bottom. + await tester.drag(find.byType(StreamMessageListView), const Offset(0, -1000)); + await tester.pumpAndSettle(); + + // The scroll-to-bottom button itself hides at the bottom, so the + // badge is gone too. + expect(badgeLabel(tester), isNull); + + // Scrolling away from the bottom again, with no further arrivals in + // between, must show the button with no badge — the earlier count + // should have been cleared on reaching the bottom, not just hidden. + await tester.drag(find.byType(StreamMessageListView), const Offset(0, 400)); + await tester.pumpAndSettle(); + + expect(badgeLabel(tester), isNull); + }, + ); + }); +} diff --git a/packages/stream_chat_flutter/test/src/mocks.dart b/packages/stream_chat_flutter/test/src/mocks.dart index 9b1ac43b9a..cab7f0036a 100644 --- a/packages/stream_chat_flutter/test/src/mocks.dart +++ b/packages/stream_chat_flutter/test/src/mocks.dart @@ -90,6 +90,7 @@ class MockChannelState extends Mock implements ChannelClientState { when(() => typingEventsStream).thenAnswer((_) => Stream.value({})); when(() => unreadCount).thenReturn(0); when(() => isUpToDate).thenReturn(true); + when(() => isMarkedAsUnread).thenReturn(false); when(() => read).thenReturn([]); when(() => draftStream).thenAnswer((_) => Stream.value(null)); when(() => threadDraftStream(any())).thenAnswer((_) => Stream.value(null)); From 452ed9be28de4f7ae96a2ae7a7908b13d5a08c62 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Thu, 6 Aug 2026 12:45:35 +0200 Subject: [PATCH 04/14] feat(i18n): add unreadMessagesSeparatorLabel translations 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 --- packages/stream_chat_localizations/CHANGELOG.md | 1 + .../stream_chat_localizations/example/lib/add_new_lang.dart | 6 ++++++ .../lib/src/stream_chat_localizations_ca.dart | 6 ++++++ .../lib/src/stream_chat_localizations_de.dart | 6 ++++++ .../lib/src/stream_chat_localizations_en.dart | 6 ++++++ .../lib/src/stream_chat_localizations_es.dart | 6 ++++++ .../lib/src/stream_chat_localizations_fr.dart | 6 ++++++ .../lib/src/stream_chat_localizations_hi.dart | 6 ++++++ .../lib/src/stream_chat_localizations_it.dart | 6 ++++++ .../lib/src/stream_chat_localizations_ja.dart | 5 +++++ .../lib/src/stream_chat_localizations_ko.dart | 5 +++++ .../lib/src/stream_chat_localizations_no.dart | 6 ++++++ .../lib/src/stream_chat_localizations_pt.dart | 6 ++++++ .../stream_chat_localizations/test/translations_test.dart | 3 ++- 14 files changed, 73 insertions(+), 1 deletion(-) diff --git a/packages/stream_chat_localizations/CHANGELOG.md b/packages/stream_chat_localizations/CHANGELOG.md index d583d86b58..2c6bc6bc56 100644 --- a/packages/stream_chat_localizations/CHANGELOG.md +++ b/packages/stream_chat_localizations/CHANGELOG.md @@ -3,6 +3,7 @@ ✅ Added - Added connection-error translations (`connectionErrorTitle`/`Description`, `slowConnectionErrorTitle`/`Description`, `genericErrorTitle`/`Description`) for all supported locales. +- Added `unreadMessagesSeparatorLabel` for all supported locales. ## 10.2.0 diff --git a/packages/stream_chat_localizations/example/lib/add_new_lang.dart b/packages/stream_chat_localizations/example/lib/add_new_lang.dart index 0e3fcfe28a..bf0edb489b 100644 --- a/packages/stream_chat_localizations/example/lib/add_new_lang.dart +++ b/packages/stream_chat_localizations/example/lib/add_new_lang.dart @@ -510,6 +510,12 @@ class NnStreamChatLocalizations extends GlobalStreamChatLocalizations { @override String unreadMessagesSeparatorText() => 'New messages'; + @override + String unreadMessagesSeparatorLabel({required int count}) { + if (count == 1) return '1 unread message'; + return '$count unread messages'; + } + @override String get enableFileAccessMessage => 'Enable file access to continue'; diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart index 063cd39e74..abedc50ec2 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart @@ -495,6 +495,12 @@ class StreamChatLocalizationsCa extends GlobalStreamChatLocalizations { @override String unreadMessagesSeparatorText() => 'Missatges nous'; + @override + String unreadMessagesSeparatorLabel({required int count}) { + if (count == 1) return '1 missatge no llegit'; + return '$count missatges no llegits'; + } + @override String get enableFileAccessMessage => "Habilita l'accés als fitxers per poder compartir-los amb amics"; diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart index d0be1bc668..dac3b8249b 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart @@ -492,6 +492,12 @@ class StreamChatLocalizationsDe extends GlobalStreamChatLocalizations { @override String unreadMessagesSeparatorText() => 'Neue Nachrichten'; + @override + String unreadMessagesSeparatorLabel({required int count}) { + if (count == 1) return '1 ungelesene Nachricht'; + return '$count ungelesene Nachrichten'; + } + @override String get enableFileAccessMessage => 'Bitte aktivieren Sie den Zugriff auf Dateien, damit Sie sie mit Freunden teilen können.'; diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart index 09caf5330a..9c925ea9f3 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart @@ -494,6 +494,12 @@ class StreamChatLocalizationsEn extends GlobalStreamChatLocalizations { @override String unreadMessagesSeparatorText() => 'New messages'; + @override + String unreadMessagesSeparatorLabel({required int count}) { + if (count == 1) return '1 unread message'; + return '$count unread messages'; + } + @override String get enableFileAccessMessage => 'Please enable access to files so you can share them with friends.'; diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart index 2f90d3f4ed..bac21258e0 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart @@ -498,6 +498,12 @@ No es posible añadir más de $limit archivos adjuntos @override String unreadMessagesSeparatorText() => 'Nuevos mensajes'; + @override + String unreadMessagesSeparatorLabel({required int count}) { + if (count == 1) return '1 mensaje no leído'; + return '$count mensajes no leídos'; + } + @override String get enableFileAccessMessage => 'Habilite el acceso a los archivos para poder compartirlos con amigos.'; diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart index 77d17595f4..35f2e1af68 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart @@ -498,6 +498,12 @@ Limite de pièces jointes dépassée : il n'est pas possible d'ajouter plus de $ @override String unreadMessagesSeparatorText() => 'Nouveaux messages'; + @override + String unreadMessagesSeparatorLabel({required int count}) { + if (count == 1) return '1 message non lu'; + return '$count messages non lus'; + } + @override String get enableFileAccessMessage => "Veuillez autoriser l'accès aux fichiers afin de pouvoir les partager avec des amis."; diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart index eaaea55d36..d6ae8a2fdb 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart @@ -496,6 +496,12 @@ class StreamChatLocalizationsHi extends GlobalStreamChatLocalizations { @override String unreadMessagesSeparatorText() => 'नए संदेश।'; + @override + String unreadMessagesSeparatorLabel({required int count}) { + if (count == 1) return '1 अपठित संदेश'; + return '$count अपठित संदेश'; + } + @override String get enableFileAccessMessage => 'कृपया फ़ाइलों तक पहुंच सक्षम करें ताकि आप उन्हें मित्रों के साथ साझा कर सकें।'; diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart index 1510cbab32..6986d8c5e4 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart @@ -501,6 +501,12 @@ Attenzione: il limite massimo di $limit file è stato superato. @override String unreadMessagesSeparatorText() => 'Nuovi messaggi'; + @override + String unreadMessagesSeparatorLabel({required int count}) { + if (count == 1) return '1 messaggio non letto'; + return '$count messaggi non letti'; + } + @override String get enableFileAccessMessage => "Per favore attiva l'accesso ai file cosí potrai condividerli con i tuoi amici."; diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart index 3e443c6f05..0dafeeefb0 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart @@ -484,6 +484,11 @@ class StreamChatLocalizationsJa extends GlobalStreamChatLocalizations { @override String unreadMessagesSeparatorText() => '新しいメッセージ。'; + @override + String unreadMessagesSeparatorLabel({required int count}) { + return '未読メッセージ $count 件'; + } + @override String get enableFileAccessMessage => '友達と共有できるように、ファイルへのアクセスを有効にしてください。'; diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart index f22a77c352..4c06b62894 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart @@ -485,6 +485,11 @@ class StreamChatLocalizationsKo extends GlobalStreamChatLocalizations { @override String unreadMessagesSeparatorText() => '새 메시지.'; + @override + String unreadMessagesSeparatorLabel({required int count}) { + return '읽지 않은 메시지 $count개'; + } + @override String get enableFileAccessMessage => '친구와 공유할 수 있도록 파일에 대한 액세스를 허용하세요.'; diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart index 180160c55d..6141413485 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart @@ -436,6 +436,12 @@ class StreamChatLocalizationsNo extends GlobalStreamChatLocalizations { @override String unreadMessagesSeparatorText() => 'Nye meldinger.'; + @override + String unreadMessagesSeparatorLabel({required int count}) { + if (count == 1) return '1 ulest melding'; + return '$count uleste meldinger'; + } + @override String get couldNotReadBytesFromFileError => 'Kunne ikke lese bytes fra filen.'; diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart index 5909875c1f..23e2c78e4f 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart @@ -497,6 +497,12 @@ Não é possível adicionar mais de $limit arquivos de uma vez @override String unreadMessagesSeparatorText() => 'Novas mensagens'; + @override + String unreadMessagesSeparatorLabel({required int count}) { + if (count == 1) return '1 mensagem não lida'; + return '$count mensagens não lidas'; + } + @override String get enableFileAccessMessage => 'Ative o acesso aos arquivos para poder compartilhá-los com amigos.'; diff --git a/packages/stream_chat_localizations/test/translations_test.dart b/packages/stream_chat_localizations/test/translations_test.dart index a763436c90..9200af68e6 100644 --- a/packages/stream_chat_localizations/test/translations_test.dart +++ b/packages/stream_chat_localizations/test/translations_test.dart @@ -229,7 +229,8 @@ void main() { expect(localizations.enableFileAccessMessage, isNotNull); expect(localizations.allowFileAccessMessage, isNotNull); expect(localizations.unreadCountIndicatorLabel(unreadCount: 2), isNotNull); - expect(localizations.unreadMessagesSeparatorText(), isNotNull); + expect(localizations.unreadMessagesSeparatorLabel(count: 1), isNotNull); + expect(localizations.unreadMessagesSeparatorLabel(count: 2), isNotNull); expect(localizations.markUnreadError, isNotNull); expect(localizations.markAsUnreadLabel, isNotNull); // Create poll From 5c3e35ec5b1d21fa5834256561c70b6198c49af6 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Thu, 6 Aug 2026 16:09:02 +0200 Subject: [PATCH 05/14] test improvements --- .../test/src/client/channel_test.dart | 142 ++++++++++++++++++ 1 file changed, 142 insertions(+) diff --git a/packages/stream_chat/test/src/client/channel_test.dart b/packages/stream_chat/test/src/client/channel_test.dart index d3330511d8..d550c8fcbc 100644 --- a/packages/stream_chat/test/src/client/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel_test.dart @@ -6786,6 +6786,120 @@ void main() { }, ); + group('isMarkedAsUnread', () { + setUp(() { + // A message.read event from the current user also reconciles + // delivery status — stub it so that call doesn't throw. + when( + () => client.channelDeliveryReporter.reconcileDelivery(any()), + ).thenAnswer((_) async {}); + }); + + test('defaults to false', () { + expect(channel.state?.isMarkedAsUnread, isFalse); + }); + + test( + 'is set by a notification.mark_unread event from the current user', + () async { + final currentUser = client.state.currentUser!; + + final markUnreadEvent = Event( + cid: channel.cid, + type: EventType.notificationMarkUnread, + user: currentUser, + lastReadAt: DateTime(2019), + unreadMessages: 5, + ); + client.addEvent(markUnreadEvent); + await Future.delayed(Duration.zero); + + expect(channel.state?.isMarkedAsUnread, isTrue); + }, + ); + + test( + 'is NOT set by a notification.mark_unread event from a different user', + () async { + final markUnreadEvent = Event( + cid: channel.cid, + type: EventType.notificationMarkUnread, + user: User(id: 'someone-else'), + lastReadAt: DateTime(2019), + unreadMessages: 5, + ); + client.addEvent(markUnreadEvent); + await Future.delayed(Duration.zero); + + expect(channel.state?.isMarkedAsUnread, isFalse); + }, + ); + + test( + 'is cleared by a message.read event from the current user', + () async { + final currentUser = client.state.currentUser!; + + client.addEvent( + Event( + cid: channel.cid, + type: EventType.notificationMarkUnread, + user: currentUser, + lastReadAt: DateTime(2019), + unreadMessages: 5, + ), + ); + await Future.delayed(Duration.zero); + expect(channel.state?.isMarkedAsUnread, isTrue); + + client.addEvent( + Event( + cid: channel.cid, + type: EventType.messageRead, + user: currentUser, + createdAt: DateTime(2022), + unreadMessages: 0, + ), + ); + await Future.delayed(Duration.zero); + + expect(channel.state?.isMarkedAsUnread, isFalse); + }, + ); + + test( + 'is NOT cleared by a message.read event from a different user', + () async { + final currentUser = client.state.currentUser!; + + client.addEvent( + Event( + cid: channel.cid, + type: EventType.notificationMarkUnread, + user: currentUser, + lastReadAt: DateTime(2019), + unreadMessages: 5, + ), + ); + await Future.delayed(Duration.zero); + expect(channel.state?.isMarkedAsUnread, isTrue); + + client.addEvent( + Event( + cid: channel.cid, + type: EventType.messageRead, + user: User(id: 'someone-else'), + createdAt: DateTime(2022), + unreadMessages: 0, + ), + ); + await Future.delayed(Duration.zero); + + expect(channel.state?.isMarkedAsUnread, isTrue); + }, + ); + }); + test('should update read state on message delivered event', () async { final currentUser = User(id: 'test-user'); final distantPast = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); @@ -10775,6 +10889,34 @@ void main() { }, ); + test( + 'markUnreadByTimestamp sets isMarkedAsUnread locally', + () async { + final channel = _createLivestreamChannel(); + expect(channel.state?.isMarkedAsUnread, isFalse); + + await expectLater( + channel.markUnreadByTimestamp(DateTime(2024, 1, 1)), + completes, + ); + + expect(channel.state?.isMarkedAsUnread, isTrue); + }, + ); + + test( + 'markRead clears isMarkedAsUnread locally', + () async { + final channel = _createLivestreamChannel(); + await channel.markUnreadByTimestamp(DateTime(2024, 1, 1)); + expect(channel.state?.isMarkedAsUnread, isTrue); + + await expectLater(channel.markRead(), completes); + + expect(channel.state?.isMarkedAsUnread, isFalse); + }, + ); + group('local read boundary anchors', () { final start = DateTime(2024, 1, 1); final messages = [ From 0808f07d651022e9d8f5f777a2cc50987a78b4fd Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Fri, 7 Aug 2026 10:03:32 +0200 Subject: [PATCH 06/14] fix review comments --- .../message_list_view/mark_read_details.dart | 2 + .../message_list_view/message_list_view.dart | 47 +++++++++++----- ...tream_message_list_view_configuration.dart | 6 +++ .../src/message_list_view/mark_read_test.dart | 54 ++++++++++++++++++- .../lib/src/stream_channel.dart | 3 ++ 5 files changed, 97 insertions(+), 15 deletions(-) diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/mark_read_details.dart b/packages/stream_chat_flutter/lib/src/message_list_view/mark_read_details.dart index d2b4fda27d..b0d9a7e660 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view/mark_read_details.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view/mark_read_details.dart @@ -1,3 +1,5 @@ +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + /// The information available when deciding whether to automatically mark a /// [StreamMessageListView]'s channel as read. /// diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart index e84be35481..07779e0ef4 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart @@ -357,7 +357,13 @@ class _StreamMessageListViewState extends State { // burned on capturing the baseline instead of acting on it. Cleared once // a mark-read actually goes through, or once `isMarkedAsUnread` itself // clears (so a future mark-unread starts its own fresh snapshot). - Iterable? _markUnreadViewportSnapshot; + // + // Holds visible item *indices* rather than full [ItemPosition]s: comparing + // full positions would latch divergence on a sub-pixel edge change from an + // unrelated relayout (async attachment sizing, keyboard inset, image + // load) even though the user never scrolled, undoing the manual + // mark-unread almost instantly. + List? _markUnreadViewportSnapshot; // Sticky once true: sighted the first time [_handleItemPositionsChanged] // (or, as a fallback, [_maybeMarkMessagesAsRead] itself) sees item @@ -424,7 +430,7 @@ class _StreamMessageListViewState extends State { // read-stream emission while still marked unread shouldn't keep // chasing the latest position and never let a genuine scroll differ // from it. - _markUnreadViewportSnapshot ??= _itemPositionListener.itemPositions.value.toList(); + _markUnreadViewportSnapshot ??= _itemPositionListener.itemPositions.value.map((it) => it.index).toList(); } else { _markUnreadViewportSnapshot = null; _markUnreadViewportDiverged = false; @@ -603,7 +609,10 @@ class _StreamMessageListViewState extends State { _highlightState.value = (id: messageId, generation: _highlightState.value.generation + 1); } - Future _scrollToMessage({ + // Returns whether the list actually scrolled to `messageId` — `false` for + // any of the bail-out paths below (target not found even after + // pagination, widget unmounted mid-pagination, or the SPL not attached). + Future _scrollToMessage({ required String messageId, double alignment = 0.5, // center the message in the viewport by default bool highlight = true, @@ -617,7 +626,7 @@ class _StreamMessageListViewState extends State { if (index < 0) { // No around-reply pagination in thread mode yet — bail rather than // clobber the parent channel's loaded window. - if (_isThreadConversation) return; + if (_isThreadConversation) return false; // Target isn't in the loaded channel window. Paginate around it, wait // one frame for the BetterStreamBuilder rebuild to flush `messages`, @@ -625,17 +634,17 @@ class _StreamMessageListViewState extends State { // `_buildListView` on each emission, so an index captured before the // await would be stale. await streamChannel!.loadChannelAtMessage(messageId); - if (!mounted) return; + if (!mounted) return false; await WidgetsBinding.instance.endOfFrame; - if (!mounted) return; + if (!mounted) return false; index = messages.indexWhere((m) => m.id == messageId); - if (index < 0) return; + if (index < 0) return false; } // Bail when the SPL isn't attached — `scrollTo` would throw, and // highlighting an off-screen message is meaningless. final controller = _scrollController; - if (controller == null || !controller.isAttached) return; + if (controller == null || !controller.isAttached) return false; // Wait for the scroll to settle before flagging the message as // highlighted; otherwise the highlight tween fires while the list is @@ -647,6 +656,7 @@ class _StreamMessageListViewState extends State { ); if (highlight && mounted) _highlightMessage(messageId); + return true; } // Wraps [child] in the highlight pulse if [message] is the currently @@ -1105,12 +1115,16 @@ class _StreamMessageListViewState extends State { final anchorId = _unreadDivider.value.anchorId ?? _unreadBaseline?.lastReadMessageId; if (anchorId == null) return; - _hasSeenFirstUnread.value = true; // Delegates to [_scrollToMessage], which falls back to // [StreamChannelState.loadChannelAtMessage] when the anchor isn't in the // currently loaded window — after which the real anchor resolves // naturally via the retry in [_buildListView], rendering divider A too. - await _scrollToMessage(messageId: anchorId, highlight: false); + final didJump = await _scrollToMessage(messageId: anchorId, highlight: false); + // Only claim the boundary as seen once the jump actually landed — + // otherwise (message not found even after pagination, or the SPL not + // attached) the pill would vanish and the mark-read gate would open for + // a boundary the user never actually reached. + if (didJump && mounted) _hasSeenFirstUnread.value = true; } Future _onUnreadPillDismissTap() async { @@ -1440,15 +1454,22 @@ class _StreamMessageListViewState extends State { // unchanged and re-block a mark-read the round trip should already have // earned. Safe to call on every position-changed tick — a no-op once // already diverged. + // + // Compares the set of visible item *indices* rather than full + // [ItemPosition]s (which also carry leading/trailing edge offsets) — an + // unrelated relayout that nudges an edge by a fraction of a pixel isn't + // evidence the user did anything, and shouldn't count as divergence. void _checkMarkUnreadViewportDivergence(Iterable itemPositions) { + final visibleIndices = itemPositions.map((it) => it.index).toList(); + if (_markUnreadViewportSnapshot == null) { - _markUnreadViewportSnapshot = itemPositions.toList(); + _markUnreadViewportSnapshot = visibleIndices; return; } if (_markUnreadViewportDiverged) return; - const positionsEquality = UnorderedIterableEquality(); - if (!positionsEquality.equals(itemPositions, _markUnreadViewportSnapshot)) { + const indicesEquality = UnorderedIterableEquality(); + if (!indicesEquality.equals(visibleIndices, _markUnreadViewportSnapshot)) { _markUnreadViewportDiverged = true; } } diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/stream_message_list_view_configuration.dart b/packages/stream_chat_flutter/lib/src/message_list_view/stream_message_list_view_configuration.dart index 263fd59bf0..afdf1db723 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view/stream_message_list_view_configuration.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view/stream_message_list_view_configuration.dart @@ -55,6 +55,12 @@ class StreamMessageListViewConfiguration { /// /// Only affects channel reads; has no effect on thread reads or on /// [markReadWhenAtTheBottom] being `false`. + /// + /// Participates in this configuration's `==`/`hashCode`, so an inline + /// closure gives every rebuild a new identity and can make otherwise + /// identical configurations compare unequal. Hosts that rely on + /// configuration equality should hoist the predicate into a field or a + /// static function instead. final StreamShouldMarkReadPredicate? shouldMarkRead; /// Whether swiping a message triggers a quoted-reply action. diff --git a/packages/stream_chat_flutter/test/src/message_list_view/mark_read_test.dart b/packages/stream_chat_flutter/test/src/message_list_view/mark_read_test.dart index 27efe5dda3..fa3fc95e9d 100644 --- a/packages/stream_chat_flutter/test/src/message_list_view/mark_read_test.dart +++ b/packages/stream_chat_flutter/test/src/message_list_view/mark_read_test.dart @@ -330,16 +330,29 @@ void main() { (tester) async { final other = User(id: 'otherid'); final messages = generateConversation(20, users: [other]).reversed.toList(); + StreamMarkReadDetails? capturedDetails; await pumpMessageList( tester, messages: messages, isUpToDate: true, unreadCount: 5, - shouldMarkRead: (details) => false, + shouldMarkRead: (details) { + capturedDetails = details; + return false; + }, ); verifyNever(() => channel.markRead(messageId: any(named: 'messageId'))); + + // Opened at the bottom with nothing pre-existing unread and no + // active manual mark-unread — the default gating would have + // allowed this; only the override blocks it. + expect(capturedDetails, isNotNull); + expect(capturedDetails!.unreadCount, 5); + expect(capturedDetails!.hasSeenLastMessage, isTrue); + expect(capturedDetails!.hasSeenFirstUnreadMessage, isTrue); + expect(capturedDetails!.isMarkedAsUnread, isFalse); }, ); @@ -349,6 +362,7 @@ void main() { final other = User(id: 'otherid'); final messages = generateConversation(20, users: [other]).reversed.toList(); final lastReadMessageId = messages[10].id; + StreamMarkReadDetails? capturedDetails; await pumpMessageList( tester, @@ -362,10 +376,46 @@ void main() { unreadMessages: 5, lastReadMessageId: lastReadMessageId, ), - shouldMarkRead: (details) => true, + shouldMarkRead: (details) { + capturedDetails = details; + return true; + }, ); verify(() => channel.markRead(messageId: any(named: 'messageId'))).called(1); + + // The unseen pre-existing unread boundary is exactly what the + // default gating would have blocked on; the override allows it + // anyway. + expect(capturedDetails, isNotNull); + expect(capturedDetails!.unreadCount, 5); + expect(capturedDetails!.hasSeenFirstUnreadMessage, isFalse); + expect(capturedDetails!.isMarkedAsUnread, isFalse); + }, + ); + + testWidgets( + 'a shouldMarkRead override sees isMarkedAsUnread when the channel has an active manual mark-unread', + (tester) async { + final other = User(id: 'otherid'); + final messages = generateConversation(20, users: [other]).reversed.toList(); + when(() => channelClientState.isMarkedAsUnread).thenReturn(true); + StreamMarkReadDetails? capturedDetails; + + await pumpMessageList( + tester, + messages: messages, + isUpToDate: true, + unreadCount: 5, + shouldMarkRead: (details) { + capturedDetails = details; + return false; + }, + ); + + expect(capturedDetails, isNotNull); + expect(capturedDetails!.isMarkedAsUnread, isTrue); + expect(capturedDetails!.unreadCount, 5); }, ); }); diff --git a/packages/stream_chat_flutter_core/lib/src/stream_channel.dart b/packages/stream_chat_flutter_core/lib/src/stream_channel.dart index 5e4232f356..eb0b6a7dc7 100644 --- a/packages/stream_chat_flutter_core/lib/src/stream_channel.dart +++ b/packages/stream_chat_flutter_core/lib/src/stream_channel.dart @@ -88,6 +88,9 @@ class StreamChannel extends StatefulWidget { /// /// Has no effect on [StreamChannel.value], which never repositions the /// loaded window. + /// + /// Only read once, during channel initialization — changing it after this + /// widget has mounted does not reposition the current viewport. final bool openAtFirstUnread; /// Widget builder used while the channel is initialising. From 27e1a51a76b3208cd88d08d052e0ca278e6e1161 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Fri, 7 Aug 2026 10:06:07 +0200 Subject: [PATCH 07/14] fix(ui): dispose _showScrollToBottom notifier Missed in the previous review-comment pass; it's created alongside the other mark-read/unread notifiers and needs the same teardown. --- .../lib/src/message_list_view/message_list_view.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart index 07779e0ef4..78e590f276 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart @@ -593,6 +593,7 @@ class _StreamMessageListViewState extends State { _unreadDividerGrowth.dispose(); _hasSeenFirstUnread.dispose(); _scrollToBottomBadge.dispose(); + _showScrollToBottom.dispose(); _highlightState.dispose(); super.dispose(); } From 241908e9c191558bb61351ad166960fa6d438d6d Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Thu, 20 Aug 2026 12:45:41 +0200 Subject: [PATCH 08/14] improvements based on PR review --- CLAUDE.md | 37 ++ .../stream_chat/lib/src/client/channel.dart | 3 +- packages/stream_chat_flutter/CHANGELOG.md | 14 +- .../lib/src/localization/translations.dart | 13 +- .../message_list_view/mark_read_details.dart | 44 --- .../message_list_view/message_list_view.dart | 169 ++++++--- ...tream_message_list_view_configuration.dart | 26 +- .../unread_indicator_button.dart | 73 ++-- .../lib/stream_chat_flutter.dart | 1 - .../src/message_list_view/mark_read_test.dart | 333 ++++++++++++------ .../unread_divider_test.dart | 164 +++++++++ .../unread_indicator_button_test.dart | 132 +++++++ .../stream_chat_localizations/CHANGELOG.md | 5 +- .../example/lib/add_new_lang.dart | 1 + .../lib/src/stream_chat_localizations.dart | 12 + .../lib/src/stream_chat_localizations_ca.dart | 1 + .../lib/src/stream_chat_localizations_de.dart | 1 + .../lib/src/stream_chat_localizations_en.dart | 1 + .../lib/src/stream_chat_localizations_es.dart | 1 + .../lib/src/stream_chat_localizations_fr.dart | 1 + .../lib/src/stream_chat_localizations_hi.dart | 1 + .../lib/src/stream_chat_localizations_it.dart | 1 + .../lib/src/stream_chat_localizations_ja.dart | 1 + .../lib/src/stream_chat_localizations_ko.dart | 1 + .../lib/src/stream_chat_localizations_no.dart | 1 + .../lib/src/stream_chat_localizations_pt.dart | 1 + .../test/translations_test.dart | 3 + 27 files changed, 793 insertions(+), 248 deletions(-) delete mode 100644 packages/stream_chat_flutter/lib/src/message_list_view/mark_read_details.dart create mode 100644 packages/stream_chat_flutter/test/src/message_list_view/unread_indicator_button_test.dart diff --git a/CLAUDE.md b/CLAUDE.md index 36249bf8d2..7edf4a96d6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -125,6 +125,43 @@ Optional local persistence using Drift (SQLite). Implements `ChatPersistenceClie - Trailing commas: `preserve` (formatter setting) - Generated files (`.g.dart`, `.freezed.dart`) are excluded from analysis +## Breaking Changes + +This is a published SDK: every symbol exported from a package's barrel +(`lib/.dart`) is public API that customers may already depend on. + +**Always ask the user for explicit permission before making a change that could break +customer code.** Propose the change, name what breaks and who it affects, offer a +non-breaking alternative if one exists, and wait for a decision. Do not assume a change +is acceptable because it is small, "unlikely to be used", or internally more correct. + +Treat all of the following as potentially breaking, even when the diff looks trivial: + +- Removing, renaming, or moving a public class, method, getter, typedef, or extension +- Changing a constructor parameter's type, name, or nullability — including changing a + callback signature (e.g. `void Function(String?)` → `void Function()`) +- Adding a `required` parameter to an existing public constructor or method +- Adding a member to, or changing a member's signature on, an interface customers + implement or subclass (e.g. `Translations`, `ChatPersistenceClient`, theme data classes) +- Changing a default value, or changing which widget/behaviour a public widget renders +- Making a public widget stop reading state it used to read (a customer's override or + wrapper may silently stop taking effect — a *behavioural* break with no compile error) +- Changing the semantics of an existing field without changing its type + +Behavioural breaks deserve the same scrutiny as compile breaks; they are worse, because +customers get no compiler warning. + +When a breaking change is approved: + +- Prefer the non-breaking path where it exists: add the new API alongside the old one, + `@Deprecated('Use X instead.')` the old one, and keep it for at least one minor release. +- Make new parameters optional with a default that preserves the previous behaviour. +- Use `refactor(scope)!:` / `feat(scope)!:` in the commit and PR title. +- Record it in the package's `CHANGELOG.md` under `🔄 Changed` (or `⚠️ Deprecated`), + spelling out the migration for customers. +- If a translation key or theme property stops being used, deprecate it rather than + leaving it silently dead. + ## PR & Commit Conventions PR titles follow [Conventional Commits](https://www.conventionalcommits.org/): diff --git a/packages/stream_chat/lib/src/client/channel.dart b/packages/stream_chat/lib/src/client/channel.dart index 783cd79a02..966c8985e1 100644 --- a/packages/stream_chat/lib/src/client/channel.dart +++ b/packages/stream_chat/lib/src/client/channel.dart @@ -3678,8 +3678,7 @@ class ChannelClientState { /// 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 — mirrors the iOS SDK's - /// `ReadStateHandler.isMarkedAsUnread`. + /// immediately undo a manual mark-unread. bool get isMarkedAsUnread => _isMarkedAsUnread; bool _isMarkedAsUnread = false; diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index f5814518bd..ccf0501847 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -8,18 +8,17 @@ - Added a `size` (`StreamLoadingSpinnerSize`) parameter to `StreamScrollViewLoadingWidget`. - Added `onReactionTap` to `StreamMessageItem` and `StreamMessageListView`, reporting the tapped message's `BuildContext` and a `ReactionTapDetails` with the tapped `message` and `reaction` (the reaction is `null` for a clustered or overflow chip that maps to no single reaction). - Added an `unreadIndicator` parameter to `StreamBackButton` that overlays a widget (typically a `StreamUnreadIndicator`) on the button's top-end corner. Pass `StreamUnreadIndicator(excludeCid: cid)` to show the total unread count of other channels, or `StreamUnreadIndicator.channels(cid: cid)` for a single channel's count. -- Added `StreamMessageListViewConfiguration.shouldMarkRead` to fully override the automatic mark-read gating described below. - Added `Channel.isMarkedAsUnread` (via `ChannelClientState`), reporting whether the current user has an active manual mark-unread that hasn't been read past yet. - Added `StreamChannel.openAtFirstUnread` (`stream_chat_flutter_core`), defaulting to `true`. Set to `false` to always open a channel at the latest message instead of scrolling to the first pre-existing unread message. -- Added `Translations.unreadMessagesSeparatorLabel`, used by the default `UnreadMessagesSeparator` to show a count, e.g. "5 unread messages". +- Added `Translations.unreadMessagesSeparatorLabel`, used by the default `UnreadMessagesSeparator` to show a count, e.g. "5 unread messages". It has a default implementation that falls back to the (now deprecated) `unreadMessagesSeparatorText`, so existing translation classes keep compiling and any custom text they already override keeps being shown. +- Added an optional `unreadCount` to `UnreadIndicatorButton`. When supplied, the widget renders unconditionally with that count and skips its internal read-state subscription, letting the host own visibility — this is how `StreamMessageListView` now drives it. Omitting it keeps the previous self-subscribing behaviour, and `onJumpTap` keeps its `String? lastReadMessageId` argument, so existing usages are unaffected. 🔄 Changed - Changed the "↑ N unread" jump-to-unread pill to a count fixed when the channel opens, staying on screen for the whole session rather than reacting to the live, shrinking unread count. The pill now shows as soon as that count is known — even before the boundary message itself has loaded — and dismisses permanently for the session once tapped, dismissed, or scrolled past; it no longer reappears when a new message arrives. - Changed the scroll-to-bottom badge to count only messages that arrive out of view during the current session, rather than being seeded from the channel's unread count. It always resets to 0 once the user reaches the bottom. - Changed the "unread messages" divider to show a count, starting at the channel's open-time unread total and counting up as further messages arrive during the session — mirroring WhatsApp — instead of a fixed, count-less label. -- Changed `UnreadIndicatorButton` to take a `required int unreadCount` and render unconditionally, dropping its internal read-state subscription — `StreamMessageListView` now owns its visibility. -- Tightened `StreamMessageListView`'s automatic mark-read gating to also require that the pre-existing unread boundary (if any) has been seen or scrolled past, and that there's no pending manual mark-unread — mirroring the iOS SDK. Previously, reaching the bottom with unread messages present was sufficient. +- Tightened `StreamMessageListView`'s automatic mark-read gating to also require that the pre-existing unread boundary (if any) has been seen or scrolled past, and that there's no pending manual mark-unread. Previously, reaching the bottom with unread messages present was sufficient. ⚠️ Deprecated @@ -27,6 +26,7 @@ - Deprecated `onReactionsTap` (and the `OnReactionsTap` typedef) on `StreamMessageItem` and `StreamMessageListView` in favor of `onReactionTap`. - Deprecated `height`/`width` of `StreamScrollViewLoadingWidget` in favor of `size`. - Deprecated `StreamBackButton.showUnreadCount` and `StreamBackButton.channelId` in favor of `unreadIndicator`. +- Deprecated `Translations.unreadMessagesSeparatorText` in favor of `unreadMessagesSeparatorLabel`, which takes a `count`. The old string is still used as the fallback for translation classes that haven't overridden the new one. 🐞 Fixed @@ -35,6 +35,12 @@ - Fixed `StreamTypingIndicator` briefly showing typing users from a different context (main channel vs. thread) on its first frame. - Fixed the attachment picker throwing a `Tooltip` assertion error when a custom `TabbedAttachmentPickerOption` is added without a `title`; the tooltip is now only shown when a title is provided. - Fixed the `StreamBackButton` unread badge including the currently open channel in its total count. +- Fixed messages arriving while the user was mid-drag or mid-fling being dropped from the scroll-to-bottom badge and the unread divider's count. The "don't fight a scroll in motion" guard ran before the counting, so those arrivals were never counted at all. +- Fixed the scroll-to-bottom badge and unread divider counting messages the channel's own unread count ignores — silent, shadowed, ephemeral, thread-only, restricted, and muted-sender messages no longer inflate either counter. +- Fixed thread reads being blocked whenever the parent channel wasn't up to date. `markThreadRead` no longer consults the channel's `isUpToDate`, which is unrelated to a thread's own read state. +- Fixed the jump-to-unread pill being dismissed by the slightest scroll after marking a message unread. Its anchor is the message the user just acted on, so it starts out on screen; only scrolling past it now retires the pill. +- Fixed the jump-to-unread pill flickering back in and straight out on every new message after being dismissed. The mark-unread reset now runs on the transition into the marked-unread state rather than on every read-state emission while it is set. +- Fixed tapping the jump-to-unread pill doing nothing on a channel the current user has never opened, where there is no read boundary to jump to. It now scrolls to the oldest loaded message and pulls in the next page, leaving the pill up until the real boundary is reached. - Fixed `StreamMessageListView` jumping several screens when selecting text in a message on desktop or web. The `ScrollablePositionedList` viewports now account for their `anchor` in `getOffsetToReveal`, so implicit reveals (`Scrollable.ensureVisible`, `RenderObject.showOnScreen`) no longer overshoot. [#2862](https://github.com/GetStream/stream-chat-flutter/issues/2862) ## 10.2.0 diff --git a/packages/stream_chat_flutter/lib/src/localization/translations.dart b/packages/stream_chat_flutter/lib/src/localization/translations.dart index 0a96e78e6b..cb2a2d343c 100644 --- a/packages/stream_chat_flutter/lib/src/localization/translations.dart +++ b/packages/stream_chat_flutter/lib/src/localization/translations.dart @@ -100,11 +100,21 @@ abstract class Translations { /// The text for showing the unread messages count /// in the [StreamMessageListView] + @Deprecated('Use unreadMessagesSeparatorLabel instead. Will be removed in the next major version.') String unreadMessagesSeparatorText(); /// The label for the unread messages separator in the /// [StreamMessageListView], e.g. "5 unread messages". - String unreadMessagesSeparatorLabel({required int count}); + /// + /// Defaults to the count-less `unreadMessagesSeparatorText` so that + /// implementations written before this method existed — including ones + /// that customise only that older string — keep rendering their own text + /// rather than silently reverting to the built-in copy. Override this to + /// show the count. + String unreadMessagesSeparatorLabel({required int count}) { + // ignore: deprecated_member_use_from_same_package + return unreadMessagesSeparatorText(); + } /// The label for "connected" in [StreamConnectionStatusBuilder] String get connectedLabel; @@ -1292,6 +1302,7 @@ Attachment limit exceeded: it's not possible to add more than $limit attachments String get linkDisabledError => 'Links are disabled'; @override + // ignore: deprecated_member_use_from_same_package String unreadMessagesSeparatorText() => 'New messages'; @override diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/mark_read_details.dart b/packages/stream_chat_flutter/lib/src/message_list_view/mark_read_details.dart deleted file mode 100644 index b0d9a7e660..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_list_view/mark_read_details.dart +++ /dev/null @@ -1,44 +0,0 @@ -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// The information available when deciding whether to automatically mark a -/// [StreamMessageListView]'s channel as read. -/// -/// Passed to a caller-supplied predicate on -/// [StreamMessageListViewConfiguration.shouldMarkRead]. Not intended to be -/// constructed directly. -class StreamMarkReadDetails { - /// Creates a set of details describing the current mark-read gate state. - const StreamMarkReadDetails({ - required this.hasSeenLastMessage, - required this.hasSeenFirstUnreadMessage, - required this.isMarkedAsUnread, - required this.unreadCount, - }); - - /// Whether the bottom of the list has been fully visible at some point - /// since the last successful mark-read — either it's visible right now, or - /// it was visible earlier and the user has since scrolled away. - final bool hasSeenLastMessage; - - /// Whether the user has seen (rendered on screen) or scrolled past the - /// pre-existing unread boundary captured when the channel was opened. - /// - /// Always `true` when there was nothing to see in the first place — the - /// channel opened fully read, or it uses local unread counts with read - /// events disabled. - final bool hasSeenFirstUnreadMessage; - - /// Whether the current user has an active manual mark-unread on this - /// channel that hasn't been read past yet. - final bool isMarkedAsUnread; - - /// The channel's current unread count. - final int unreadCount; -} - -/// Signature for overriding [StreamMessageListView]'s automatic mark-read -/// gating. -/// -/// Return `true` to mark the channel as read, `false` to skip it for now — -/// the list retries on the next relevant scroll or message event. -typedef StreamShouldMarkReadPredicate = bool Function(StreamMarkReadDetails details); diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart index 78e590f276..f78b7c4da3 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart @@ -375,6 +375,20 @@ class _StreamMessageListViewState extends State { // mark-read that should already have been earned by that round trip. bool _markUnreadViewportDiverged = false; + // Previous value of `channel.state.isMarkedAsUnread`, so + // [_handleCurrentUserReadChanged] can act on the *transition* into the + // marked-unread state rather than on every read-stream emission that + // happens while it's set. + bool _wasMarkedAsUnread = false; + + // Whether divider A's current session came from an explicit mark-unread + // rather than from pre-existing unread at channel open. The anchor of a + // manual mark-unread is the message the user was looking at when they + // marked it, so it's already on screen — see + // [_maybeUpdateHasSeenFirstUnread] for why that changes what counts as + // having reached the boundary. + bool _unreadFromManualMarkUnread = false; + // Captures [_unreadBaseline] the first time the current user's read state // becomes available, then attempts to resolve divider A's anchor against // it. No-ops in a thread, where divider A doesn't apply. @@ -413,25 +427,37 @@ class _StreamMessageListViewState extends State { // Reacts to a `currentUserReadStream` emission. An explicit mark-unread // moves the read boundary backward — treat it as a new session start for - // divider A/the pill, mirroring iOS's `forceUpdate` path. + // divider A/the pill. + // + // The reset is deliberately gated on the *transition* into + // `isMarkedAsUnread`, not on the flag being set: the read stream also + // emits while it stays set (every new message, for one), and re-running + // the reset then would clear `_hasSeenFirstUnread` again and flicker the + // pill back in and straight out on each arrival. void _handleCurrentUserReadChanged() { if (_isThreadConversation) return; final channel = streamChannel?.channel; if (channel == null) return; - if (channel.state?.isMarkedAsUnread ?? false) { + final isMarkedAsUnread = channel.state?.isMarkedAsUnread ?? false; + final justMarkedAsUnread = isMarkedAsUnread && !_wasMarkedAsUnread; + _wasMarkedAsUnread = isMarkedAsUnread; + + if (justMarkedAsUnread) { _unreadBaselineCaptured = false; _unreadBaseline = null; _unreadDivider.value = (count: 0, anchorId: null); _unreadDividerGrowth.value = 0; _hasSeenFirstUnread.value = false; + _unreadFromManualMarkUnread = true; // Only capture once per mark-unread session — a later, unrelated // read-stream emission while still marked unread shouldn't keep // chasing the latest position and never let a genuine scroll differ // from it. _markUnreadViewportSnapshot ??= _itemPositionListener.itemPositions.value.map((it) => it.index).toList(); - } else { + } else if (!isMarkedAsUnread) { + _unreadFromManualMarkUnread = false; _markUnreadViewportSnapshot = null; _markUnreadViewportDiverged = false; } @@ -502,6 +528,8 @@ class _StreamMessageListViewState extends State { _hasSeenLastMessage = false; _markUnreadViewportSnapshot = null; _markUnreadViewportDiverged = false; + _wasMarkedAsUnread = false; + _unreadFromManualMarkUnread = false; _captureUnreadBaselineIfNeeded(); final highlightInitialMessage = widget.config.highlightInitialMessage; @@ -526,18 +554,21 @@ class _StreamMessageListViewState extends State { _messageNewListener?.cancel(); _messageNewListener = newMessageStream?.listen((message) { - // Don't fight a scroll already in motion (drag, fling, or - // still-running animated scrollTo). - if (_scrollController?.isScrolling == true) return; - final currentUser = streamChannel?.channel.client.state.currentUser; final isAtBottom = !_showScrollToBottom.value; - // The scroll-to-bottom badge and divider A's growing count only - // apply to the channel's own message stream (not thread replies), - // and never count the current user's own messages. - final isOwnMessage = message.user?.id == currentUser?.id; - if (!_isThreadConversation && !isOwnMessage) { + // Counted before the in-motion guard below, not after: a message + // landing while the user happens to be mid-drag or mid-fling is + // exactly what the badge and divider exist to report, and bailing + // first would drop it from both counts permanently. + // + // Qualifying arrivals are filtered the same way the channel's own + // unread count filters them, so silent, shadowed, ephemeral, + // thread-only, restricted, muted-sender and own messages don't + // inflate either counter. The badge and divider also only apply to + // the channel's message stream, never to thread replies. + final countsAsUnread = _countsTowardsUnreadIndicators(message, currentUser); + if (!_isThreadConversation && countsAsUnread) { // The divider counts every qualifying arrival — including ones // seen live at the bottom — so it keeps counting up like // WhatsApp's. The badge is narrower: it only exists to flag @@ -548,6 +579,10 @@ class _StreamMessageListViewState extends State { if (!isAtBottom) _scrollToBottomBadge.value += 1; } + // Don't fight a scroll already in motion (drag, fling, or + // still-running animated scrollTo). + if (_scrollController?.isScrolling == true) return; + final details = StreamAutoScrollDetails( message: message, currentUser: currentUser, @@ -1009,7 +1044,7 @@ class _StreamMessageListViewState extends State { if (seen) return const Empty(); return UnreadIndicatorButton( unreadCount: unread.count, - onJumpTap: _onUnreadPillJumpTap, + onJumpTap: (_) => _onUnreadPillJumpTap(), onDismissTap: _onUnreadPillDismissTap, ); }, @@ -1114,7 +1149,23 @@ class _StreamMessageListViewState extends State { // so fall back to the frozen baseline's own last-read boundary, known // immediately from the server `Read`, rather than doing nothing. final anchorId = _unreadDivider.value.anchorId ?? _unreadBaseline?.lastReadMessageId; - if (anchorId == null) return; + + // A channel the user has never opened reports unread messages but has + // no read boundary at all: the anchor can't resolve until top + // pagination ends, and there's no `lastReadMessageId` to fall back on + // either. Everything loaded is unread, so head for the oldest message + // currently loaded — as far back as the boundary can be, and it pulls + // the next page in on arrival — rather than leaving the tap inert. + // + // `_hasSeenFirstUnread` is deliberately not latched here: the real + // boundary is further back than where this lands, so the pill stays up + // until it's genuinely reached. + if (anchorId == null) { + final oldestLoaded = messages.lastOrNull; + if (oldestLoaded == null) return; + await _scrollToMessage(messageId: oldestLoaded.id, highlight: false); + return; + } // Delegates to [_scrollToMessage], which falls back to // [StreamChannelState.loadChannelAtMessage] when the anchor isn't in the @@ -1396,6 +1447,45 @@ class _StreamMessageListViewState extends State { return _maybeWrapWithHighlight(message: message, child: layout); } + // Whether a freshly-arrived [message] should bump the scroll-to-bottom + // badge and divider A's growing count. + // + // This is the message- and sender-level half of + // [MessageRules.canCountAsUnread], which is what keeps silent, shadowed, + // ephemeral, thread-only, restricted, muted-sender and own messages from + // inflating either counter. + // + // The channel-level half of that rule (`isMuted`, `canUseReadReceipts`, + // `usesLocalUnreadCount`) is deliberately left out. Those govern whether + // the server tracks an unread count for the channel at all, whereas these + // two counters are purely local "what arrived while you weren't looking" + // indicators that should keep working either way — and + // `usesLocalUnreadCount` is an extension getter reading `Channel`'s + // private client field, so it can't be resolved against a channel double + // at all. + bool _countsTowardsUnreadIndicators(Message message, OwnUser? currentUser) { + if (currentUser == null) return false; + + if (message.silent) return false; + if (message.shadowed) return false; + if (message.isEphemeral) return false; + + // Thread replies don't count towards the channel's unread state unless + // they were explicitly also sent to the channel. + if (message.parentId != null && message.showInChannel != true) return false; + + final sender = message.user; + if (sender == null) return false; + if (sender.id == currentUser.id) return false; + + if (message.isNotVisibleTo(currentUser.id)) return false; + + final isSenderMuted = currentUser.mutes.any((it) => it.target.id == sender.id); + if (isSenderMuted) return false; + + return true; + } + void _handleItemPositionsChanged() { final itemPositions = _itemPositionListener.itemPositions.value; if (itemPositions.isEmpty) return; @@ -1481,6 +1571,10 @@ class _StreamMessageListViewState extends State { // when the baseline is recaptured (channel change, or an explicit // mark-unread — see [_handleCurrentUserReadChanged]). // + // Sessions started by an explicit mark-unread require scrolling *past* + // the anchor, since it starts out on screen — see + // [_unreadFromManualMarkUnread]. + // // Returns true iff this call flips [_hasSeenFirstUnread] from false to // true. bool _maybeUpdateHasSeenFirstUnread(Iterable itemPositions) { @@ -1496,12 +1590,21 @@ class _StreamMessageListViewState extends State { final visibleIndices = itemPositions.map((position) => position.index).toList(); if (visibleIndices.isEmpty) return false; - final isAnchorVisible = visibleIndices.contains(anchorItemIndex); // Smaller item indices are newer/closer to the bottom. If even the // newest visible item is older than the anchor, the anchor has scrolled // off the bottom of the viewport — the user scrolled past it. final isScrolledPast = visibleIndices.reduce(min) > anchorItemIndex; - if (!isAnchorVisible && !isScrolledPast) return false; + + if (_unreadFromManualMarkUnread) { + // The anchor of a manual mark-unread is the message the user was + // looking at when they marked it, so it's on screen from the outset. + // Counting that sighting would dismiss the pill on the very next + // layout tick — the smallest scroll, or none at all. Only actually + // scrolling past the boundary retires it. + if (!isScrolledPast) return false; + } else if (!visibleIndices.contains(anchorItemIndex) && !isScrolledPast) { + return false; + } _hasSeenFirstUnread.value = true; return true; @@ -1509,11 +1612,12 @@ class _StreamMessageListViewState extends State { // Marks messages as read if the conditions are met. // - // In a thread: the parent must have at least one reply (the server-side - // thread object doesn't exist until the first reply lands), and the - // channel must be up to date. + // In a thread: the parent must have at least one reply — the server-side + // thread object doesn't exist until the first reply lands, so + // `markThreadRead` on a reply-less parent 404s. A thread read is + // independent of where the parent channel's own loaded window sits. // - // In the channel, mirrors iOS's `shouldMarkChannelRead` gating: + // In the channel, all of: // 1. The newest page is loaded (`isUpToDate`). // 2. There is something unread to mark. // 3. The bottom has been seen — either it's visible now, or it was @@ -1527,11 +1631,9 @@ class _StreamMessageListViewState extends State { // user's action instantly. // 5. Divider A's anchor has actually been seen or scrolled past // (`hasSeenFirstUnreadMessage`) — trivially satisfied when there's - // nothing to see (the channel opened fully read) or for channels using - // local unread counts, mirroring iOS's escape hatch. - // - // A caller-supplied [StreamMessageListViewConfiguration.shouldMarkRead] - // overrides conditions 3-5. + // nothing to see (the channel opened fully read) or for channels + // using local unread counts, which have no server read state to + // anchor against. Future _maybeMarkMessagesAsRead() async { final channel = streamChannel?.channel; if (channel == null) return; @@ -1542,7 +1644,6 @@ class _StreamMessageListViewState extends State { // A server-side thread object only exists once the parent has at // least one reply; markThreadRead on a reply-less parent returns 404. if ((widget.parentMessage?.replyCount ?? 0) == 0) return; - if (!(channel.state?.isUpToDate ?? false)) return; return _debouncedMarkMessagesAsRead(); } @@ -1562,22 +1663,6 @@ class _StreamMessageListViewState extends State { final isMarkedAsUnread = channel.state?.isMarkedAsUnread ?? false; final hasSeenLastMessage = _hasSeenLastMessage || !_showScrollToBottom.value; - if (widget.config.shouldMarkRead case final shouldMarkRead?) { - final details = StreamMarkReadDetails( - hasSeenLastMessage: hasSeenLastMessage, - hasSeenFirstUnreadMessage: hasSeenFirstUnreadMessage, - isMarkedAsUnread: isMarkedAsUnread, - unreadCount: unreadCount, - ); - if (!shouldMarkRead(details)) return; - - await _debouncedMarkMessagesAsRead(); - _hasSeenLastMessage = false; - _markUnreadViewportSnapshot = null; - _markUnreadViewportDiverged = false; - return; - } - if (!hasSeenLastMessage) return; if (!hasSeenFirstUnreadMessage) return; diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/stream_message_list_view_configuration.dart b/packages/stream_chat_flutter/lib/src/message_list_view/stream_message_list_view_configuration.dart index afdf1db723..2cc256e646 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view/stream_message_list_view_configuration.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view/stream_message_list_view_configuration.dart @@ -1,6 +1,5 @@ import 'package:flutter/widgets.dart'; import 'package:stream_chat_flutter/src/message_list_view/auto_scroll_policy.dart'; -import 'package:stream_chat_flutter/src/message_list_view/mark_read_details.dart'; /// {@template streamMessageListConfiguration} /// Holds all behavior flags and non-theme, non-builder configuration for @@ -37,7 +36,6 @@ class StreamMessageListViewConfiguration { this.keyboardDismissBehavior = .onDrag, this.scrollPhysics = const ClampingScrollPhysics(), this.autoScrollPolicy = .whenOwnMessageOrAtBottom, - this.shouldMarkRead, }); /// Whether to mark the channel as read when the user scrolls to the bottom. @@ -45,24 +43,6 @@ class StreamMessageListViewConfiguration { /// Defaults to true. final bool markReadWhenAtTheBottom; - /// Overrides the built-in gating for automatic mark-read. - /// - /// When null (the default), the list marks the channel as read once the - /// bottom has been seen, the pre-existing unread boundary (if any) has - /// been seen or scrolled past, and there is no active manual mark-unread — - /// see [StreamMarkReadDetails]. Provide this to fully control the decision - /// instead. - /// - /// Only affects channel reads; has no effect on thread reads or on - /// [markReadWhenAtTheBottom] being `false`. - /// - /// Participates in this configuration's `==`/`hashCode`, so an inline - /// closure gives every rebuild a new identity and can make otherwise - /// identical configurations compare unequal. Hosts that rely on - /// configuration equality should hoist the predicate into a field or a - /// static function instead. - final StreamShouldMarkReadPredicate? shouldMarkRead; - /// Whether swiping a message triggers a quoted-reply action. /// /// Defaults to false. @@ -182,11 +162,9 @@ class StreamMessageListViewConfiguration { ScrollViewKeyboardDismissBehavior? keyboardDismissBehavior, ScrollPhysics? scrollPhysics, StreamAutoScrollPolicy? autoScrollPolicy, - StreamShouldMarkReadPredicate? shouldMarkRead, }) { return StreamMessageListViewConfiguration( markReadWhenAtTheBottom: markReadWhenAtTheBottom ?? this.markReadWhenAtTheBottom, - shouldMarkRead: shouldMarkRead ?? this.shouldMarkRead, swipeToReply: swipeToReply ?? this.swipeToReply, showScrollToBottom: showScrollToBottom ?? this.showScrollToBottom, showUnreadCountOnScrollToBottom: showUnreadCountOnScrollToBottom ?? this.showUnreadCountOnScrollToBottom, @@ -226,8 +204,7 @@ class StreamMessageListViewConfiguration { other.retentionTrimBuffer == retentionTrimBuffer && other.keyboardDismissBehavior == keyboardDismissBehavior && other.scrollPhysics == scrollPhysics && - other.autoScrollPolicy == autoScrollPolicy && - other.shouldMarkRead == shouldMarkRead; + other.autoScrollPolicy == autoScrollPolicy; } @override @@ -249,6 +226,5 @@ class StreamMessageListViewConfiguration { keyboardDismissBehavior, scrollPhysics, autoScrollPolicy, - shouldMarkRead, ); } diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/unread_indicator_button.dart b/packages/stream_chat_flutter/lib/src/message_list_view/unread_indicator_button.dart index 20727aeccc..dd7f5971f7 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view/unread_indicator_button.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view/unread_indicator_button.dart @@ -1,25 +1,32 @@ import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; import 'package:stream_chat_flutter/src/utils/extensions.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; import 'package:stream_core_flutter/chat.dart' as core; /// {@template unreadIndicatorButton} -/// A floating "jump to unread" pill showing a fixed unread count. +/// A floating "jump to unread" pill. /// -/// [UnreadIndicatorButton] is purely presentational: the host -/// [StreamMessageListView] decides when it should be visible (only while the -/// pre-existing unread boundary sits above the viewport) and supplies the -/// frozen [unreadCount]. Users can tap to navigate to the first unread +/// By default [UnreadIndicatorButton] listens to the current user's read +/// state and shows itself whenever there are unread messages, hiding again +/// once there are none. Users can tap to navigate to the oldest unread /// message or dismiss the indicator. /// +/// Pass [unreadCount] to opt out of that and drive the pill from the host +/// instead: the widget then renders unconditionally with the given count and +/// never subscribes to read state, leaving visibility entirely to the caller. +/// [StreamMessageListView] uses this mode so the pill can stay on screen with +/// the count frozen at channel open, rather than tracking the live, +/// ever-shrinking unread count. +/// /// {@tool snippet} /// /// Typical usage inside a message list: /// /// ```dart /// UnreadIndicatorButton( -/// unreadCount: 5, -/// onJumpTap: () async { -/// // scroll to the first unread message +/// onJumpTap: (lastReadMessageId) async { +/// // scroll to the unread message /// }, /// onDismissTap: () async { /// // mark channel as read @@ -30,39 +37,63 @@ import 'package:stream_core_flutter/chat.dart' as core; /// /// See also: /// -/// * [StreamMessageListView], which hosts this widget and owns its -/// visibility. +/// * [StreamMessageListView], which hosts this widget. /// {@endtemplate} class UnreadIndicatorButton extends StatelessWidget { /// Creates an unread indicator button. const UnreadIndicatorButton({ super.key, - required this.unreadCount, required this.onJumpTap, required this.onDismissTap, + this.unreadCount, }); - /// The fixed unread count to display. + /// The unread count to display, when the host owns the pill's visibility. /// - /// This is the pre-existing unread boundary's count, captured when the - /// channel was opened — it does not change for the lifetime of the - /// session. - final int unreadCount; + /// When null (the default), the count is read from the current user's read + /// state and the pill hides itself while there is nothing unread. When set, + /// the widget renders unconditionally with this count and does not + /// subscribe to read state at all — the caller decides when to show it. + final int? unreadCount; /// Called when the jump-to-unread area is tapped. - final Future Function() onJumpTap; + /// + /// Receives the ID of the last message the current user has read, which can + /// be used to scroll to that position. It is `null` when [unreadCount] is + /// supplied, since the host owns the boundary in that mode and the widget + /// never reads the channel's read state. + final Future Function(String? lastReadMessageId) onJumpTap; /// Called when the dismiss button is tapped. /// /// Typically used to mark all messages as read. final Future Function() onDismissTap; - @override - Widget build(BuildContext context) { + Widget _buildButton(BuildContext context, int count, String? lastReadMessageId) { return core.StreamJumpToUnreadButton( - label: context.translations.unreadCountIndicatorLabel(unreadCount: unreadCount), - onJumpPressed: onJumpTap, + label: context.translations.unreadCountIndicatorLabel(unreadCount: count), + onJumpPressed: () => onJumpTap(lastReadMessageId), onDismissPressed: onDismissTap, ); } + + @override + Widget build(BuildContext context) { + if (unreadCount case final count?) { + return _buildButton(context, count, null); + } + + final channel = StreamChannel.of(context).channel; + if (channel.state == null) return const Empty(); + + return BetterStreamBuilder( + initialData: channel.state!.currentUserRead, + stream: channel.state!.currentUserReadStream, + builder: (context, currentUserRead) { + final count = currentUserRead.unreadMessages; + if (count <= 0) return const Empty(); + return _buildButton(context, count, currentUserRead.lastReadMessageId); + }, + ); + } } diff --git a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart index be5848144f..2e1b0ac338 100644 --- a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart +++ b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart @@ -86,7 +86,6 @@ export 'src/message_input/stream_message_composer.dart'; export 'src/message_input/stream_message_composer_attachment_list.dart'; export 'src/message_input/stream_message_text_field.dart'; export 'src/message_list_view/auto_scroll_policy.dart'; -export 'src/message_list_view/mark_read_details.dart'; export 'src/message_list_view/message_list_view.dart'; export 'src/message_list_view/stream_message_list_view_builders.dart'; export 'src/message_list_view/stream_message_list_view_configuration.dart'; diff --git a/packages/stream_chat_flutter/test/src/message_list_view/mark_read_test.dart b/packages/stream_chat_flutter/test/src/message_list_view/mark_read_test.dart index fa3fc95e9d..57c231ece9 100644 --- a/packages/stream_chat_flutter/test/src/message_list_view/mark_read_test.dart +++ b/packages/stream_chat_flutter/test/src/message_list_view/mark_read_test.dart @@ -12,11 +12,10 @@ // past — trivially satisfied when the channel opened fully read. // 6. There is no active manual mark-unread (`channel.state.isMarkedAsUnread`). // -// `StreamMessageListViewConfiguration.shouldMarkRead` can override 4-6. -// // In a thread, it fires `channel.markThreadRead(parentId)` instead, gated -// only on the parent having at least one reply and the channel being up to -// date — conditions 4-6 don't apply there. +// only on the parent having at least one reply. A thread read is independent +// of the parent channel's own loaded window, so conditions 2 and 4-6 don't +// apply there. // // These tests pin the expected behavior so regressions in the underlying // position-listener flow (SPL `itemPositions`, scroll wiring, etc.) surface @@ -115,7 +114,6 @@ void main() { Message? parentMessage, Read? currentUserRead, bool openAtFirstUnread = false, - StreamShouldMarkReadPredicate? shouldMarkRead, }) async { when(() => channelClientState.isUpToDate).thenReturn(isUpToDate); when(() => channelClientState.unreadCount).thenReturn(unreadCount); @@ -133,19 +131,22 @@ void main() { await tester.runAsync(() async { await tester.pumpWidget( MaterialApp( - home: DefaultAssetBundle( - bundle: rootBundle, - child: StreamChat( - client: client, - themeData: StreamChatThemeData(), - child: StreamChannel( - channel: channel, - openAtFirstUnread: openAtFirstUnread, - child: StreamMessageListView( - parentMessage: parentMessage, - config: StreamMessageListViewConfiguration( - markReadWhenAtTheBottom: markReadWhenAtTheBottom, - shouldMarkRead: shouldMarkRead, + // Scaffold supplies the Material ancestor some message widgets + // need once older messages scroll into view. + home: Scaffold( + body: DefaultAssetBundle( + bundle: rootBundle, + child: StreamChat( + client: client, + themeData: StreamChatThemeData(), + child: StreamChannel( + channel: channel, + openAtFirstUnread: openAtFirstUnread, + child: StreamMessageListView( + parentMessage: parentMessage, + config: StreamMessageListViewConfiguration( + markReadWhenAtTheBottom: markReadWhenAtTheBottom, + ), ), ), ), @@ -324,129 +325,70 @@ void main() { verify(() => channel.markRead(messageId: any(named: 'messageId'))).called(1); }, ); + }); + group('thread markThreadRead gates', () { testWidgets( - 'a shouldMarkRead override that returns false blocks an otherwise-allowed mark-read', - (tester) async { - final other = User(id: 'otherid'); - final messages = generateConversation(20, users: [other]).reversed.toList(); - StreamMarkReadDetails? capturedDetails; - - await pumpMessageList( - tester, - messages: messages, - isUpToDate: true, - unreadCount: 5, - shouldMarkRead: (details) { - capturedDetails = details; - return false; - }, - ); - - verifyNever(() => channel.markRead(messageId: any(named: 'messageId'))); - - // Opened at the bottom with nothing pre-existing unread and no - // active manual mark-unread — the default gating would have - // allowed this; only the override blocks it. - expect(capturedDetails, isNotNull); - expect(capturedDetails!.unreadCount, 5); - expect(capturedDetails!.hasSeenLastMessage, isTrue); - expect(capturedDetails!.hasSeenFirstUnreadMessage, isTrue); - expect(capturedDetails!.isMarkedAsUnread, isFalse); - }, - ); - - testWidgets( - 'a shouldMarkRead override that returns true allows a mark-read the default gating would block', + 'does NOT fire when parentMessage.replyCount is 0 (thread does not yet exist server-side)', (tester) async { final other = User(id: 'otherid'); - final messages = generateConversation(20, users: [other]).reversed.toList(); - final lastReadMessageId = messages[10].id; - StreamMarkReadDetails? capturedDetails; - - await pumpMessageList( - tester, - messages: messages, - isUpToDate: true, - unreadCount: 5, - openAtFirstUnread: false, - currentUserRead: Read( - user: ownUser, - lastRead: DateTime.now(), - unreadMessages: 5, - lastReadMessageId: lastReadMessageId, - ), - shouldMarkRead: (details) { - capturedDetails = details; - return true; - }, + final parent = Message( + id: 'parent-id', + user: other, + text: 'parent', + createdAt: DateTime.utc(2026), ); - verify(() => channel.markRead(messageId: any(named: 'messageId'))).called(1); - - // The unseen pre-existing unread boundary is exactly what the - // default gating would have blocked on; the override allows it - // anyway. - expect(capturedDetails, isNotNull); - expect(capturedDetails!.unreadCount, 5); - expect(capturedDetails!.hasSeenFirstUnreadMessage, isFalse); - expect(capturedDetails!.isMarkedAsUnread, isFalse); - }, - ); - - testWidgets( - 'a shouldMarkRead override sees isMarkedAsUnread when the channel has an active manual mark-unread', - (tester) async { - final other = User(id: 'otherid'); - final messages = generateConversation(20, users: [other]).reversed.toList(); - when(() => channelClientState.isMarkedAsUnread).thenReturn(true); - StreamMarkReadDetails? capturedDetails; - await pumpMessageList( tester, - messages: messages, + parentMessage: parent, + messages: [], isUpToDate: true, - unreadCount: 5, - shouldMarkRead: (details) { - capturedDetails = details; - return false; - }, + unreadCount: 0, ); - expect(capturedDetails, isNotNull); - expect(capturedDetails!.isMarkedAsUnread, isTrue); - expect(capturedDetails!.unreadCount, 5); + verifyNever(() => channel.markThreadRead(any())); }, ); - }); - group('thread markThreadRead gates', () { testWidgets( - 'does NOT fire when parentMessage.replyCount is 0 (thread does not yet exist server-side)', + 'fires when parentMessage.replyCount > 0', (tester) async { final other = User(id: 'otherid'); final parent = Message( id: 'parent-id', user: other, text: 'parent', + replyCount: 1, createdAt: DateTime.utc(2026), ); + final reply = Message( + id: 'reply-id', + user: other, + text: 'reply', + parentId: parent.id, + createdAt: DateTime.utc(2026, 1, 1, 0, 1), + ); await pumpMessageList( tester, parentMessage: parent, - messages: [], + messages: [parent, reply], isUpToDate: true, unreadCount: 0, ); - verifyNever(() => channel.markThreadRead(any())); + verify(() => channel.markThreadRead(parent.id)).called(1); }, ); testWidgets( - 'fires when parentMessage.replyCount > 0', + 'fires even when the parent channel is not up to date', (tester) async { + // A thread read has nothing to do with where the parent channel's + // own loaded window sits — gating it on the channel's `isUpToDate` + // silently blocked thread reads whenever the channel was scrolled + // back into history. final other = User(id: 'otherid'); final parent = Message( id: 'parent-id', @@ -463,11 +405,25 @@ void main() { createdAt: DateTime.utc(2026, 1, 1, 0, 1), ); + // A channel that isn't up to date triggers a reload; stub it so the + // thread's own list still renders and the gate is actually reached. + when( + () => channel.query( + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + preferOffline: any(named: 'preferOffline'), + ), + ).thenAnswer((_) async => ChannelState(messages: [parent, reply])); + await pumpMessageList( tester, parentMessage: parent, messages: [parent, reply], - isUpToDate: true, + isUpToDate: false, unreadCount: 0, ); @@ -640,7 +596,7 @@ void main() { // Not awaited directly: `_scrollToMessage`'s fallback awaits // `WidgetsBinding.instance.endOfFrame` after the query, which only // resolves once the test binding actually pumps a frame. - unawaited(indicator.onJumpTap()); + unawaited(indicator.onJumpTap(null)); await tester.pumpAndSettle(); verify( @@ -653,6 +609,169 @@ void main() { ); }); + group('unread pill after a manual mark-unread', () { + // Marking a message unread restarts the pill's session against the + // moved-back boundary. Its anchor is the message the user was looking at, + // so it starts out on screen — which used to retire the pill on the very + // next layout tick. + Future> pumpMarkedUnread( + WidgetTester tester, { + required User other, + }) async { + final messages = generateConversation(40, users: [other]).reversed.toList(); + + await pumpMessageList( + tester, + messages: messages, + isUpToDate: true, + unreadCount: 0, + markReadWhenAtTheBottom: false, + ); + + // Now the user marks a visible message unread: the channel reports an + // active manual mark-unread and a boundary pointing back at it. + when(() => channelClientState.isMarkedAsUnread).thenReturn(true); + when(() => channelClientState.unreadCount).thenReturn(3); + final markedRead = Read( + user: ownUser, + lastRead: DateTime.now(), + unreadMessages: 3, + lastReadMessageId: messages[messages.length - 2].id, + ); + when(() => channelClientState.currentUserRead).thenReturn(markedRead); + + await tester.runAsync(() async { + unreadCountController.add(3); + currentUserReadController.add(markedRead); + await tester.pumpAndSettle(); + }); + + return messages; + } + + testWidgets( + 'a small scroll that keeps the boundary on screen does not dismiss it', + (tester) async { + final other = User(id: 'otherid'); + await pumpMarkedUnread(tester, other: other); + + expect(find.byType(UnreadIndicatorButton), findsOneWidget); + + // The slightest scroll used to be enough to retire the pill, because + // simply having the anchor visible counted as reaching the boundary. + await tester.drag(find.byType(StreamMessageListView), const Offset(0, 30)); + await tester.pumpAndSettle(); + + expect(find.byType(UnreadIndicatorButton), findsOneWidget); + }, + ); + + testWidgets( + 'stays dismissed when further messages arrive after being dismissed', + (tester) async { + final other = User(id: 'otherid'); + final messages = await pumpMarkedUnread(tester, other: other); + + expect(find.byType(UnreadIndicatorButton), findsOneWidget); + + final indicator = tester.widget( + find.byType(UnreadIndicatorButton), + ); + await indicator.onDismissTap(); + await tester.pumpAndSettle(); + + expect(find.byType(UnreadIndicatorButton), findsNothing); + + // Each arrival re-emits the read stream while `isMarkedAsUnread` is + // still set. That used to re-run the whole mark-unread reset and + // flicker the pill back in and straight out again. + final arrival = Message( + id: 'arrived-after-dismiss', + text: 'After dismiss', + user: other, + createdAt: DateTime.now(), + ); + final updated = [...messages, arrival]; + when(() => channelClientState.messages).thenReturn(updated); + final laterRead = Read( + user: ownUser, + lastRead: DateTime.now(), + unreadMessages: 4, + lastReadMessageId: messages[messages.length - 2].id, + ); + when(() => channelClientState.currentUserRead).thenReturn(laterRead); + + await tester.runAsync(() async { + messagesController.add(updated); + currentUserReadController.add(laterRead); + + // Pumped one frame at a time: the regression was a *transient* + // reappearance — the reset cleared `_hasSeenFirstUnread` on the + // read emission and the next layout latched it straight back — so + // it is invisible to an end-state assertion after pumpAndSettle. + for (var i = 0; i < 5; i++) { + await tester.pump(); + expect( + find.byType(UnreadIndicatorButton), + findsNothing, + reason: 'pill reappeared on frame $i after a later arrival', + ); + } + await tester.pumpAndSettle(); + }); + + expect(find.byType(UnreadIndicatorButton), findsNothing); + }, + ); + }); + + group('unread pill on a never-read channel', () { + testWidgets( + 'tapping jump scrolls to the oldest loaded message instead of doing nothing', + (tester) async { + // A channel the user has never opened reports unread messages with no + // read boundary at all: no `lastReadMessageId`, and the anchor can't + // resolve until top pagination ends. The tap used to be inert. + final other = User(id: 'otherid'); + final messages = generateConversation(20, users: [other]).reversed.toList(); + + await pumpMessageList( + tester, + messages: messages, + isUpToDate: true, + unreadCount: 5, + openAtFirstUnread: false, + currentUserRead: Read( + user: ownUser, + lastRead: DateTime.utc(1970), + unreadMessages: 5, + ), + ); + + expect(find.byType(UnreadIndicatorButton), findsOneWidget); + + final indicator = tester.widget( + find.byType(UnreadIndicatorButton), + ); + + // This list is in production order (oldest first), so the oldest + // loaded message is the first entry. It's already in the window, so + // the jump scrolls without needing a query. + final oldestLoaded = messages.first; + + // Not awaited directly: the scroll only completes once the test + // binding pumps frames (same reason as the fallback-jump test above). + unawaited(indicator.onJumpTap(null)); + await tester.pumpAndSettle(); + + expect(find.text(oldestLoaded.text!), findsOneWidget); + // The real boundary is further back than this landed, so the pill + // stays up rather than latching as seen. + expect(find.byType(UnreadIndicatorButton), findsOneWidget); + }, + ); + }); + group('unread indicator dismiss', () { testWidgets( 'marks the channel read immediately when tapped', diff --git a/packages/stream_chat_flutter/test/src/message_list_view/unread_divider_test.dart b/packages/stream_chat_flutter/test/src/message_list_view/unread_divider_test.dart index e431d94d8f..212d45253d 100644 --- a/packages/stream_chat_flutter/test/src/message_list_view/unread_divider_test.dart +++ b/packages/stream_chat_flutter/test/src/message_list_view/unread_divider_test.dart @@ -296,6 +296,170 @@ void main() { return tester.widget(finder).props.label; } + group('unread counting filters', () { + // Messages the channel's own unread count ignores must not inflate the + // badge or the divider either. Each case scrolls away from the bottom + // first so a counted arrival would be visible as a badge. + Future expectNotCounted( + WidgetTester tester, { + required Message Function(User other) build, + OwnUser? overrideOwnUser, + }) async { + final other = User(id: 'otherid'); + final messages = generateConversation(40, users: [other]).reversed.toList(); + + if (overrideOwnUser case final replacement?) { + when(() => clientState.currentUser).thenReturn(replacement); + } + + await pumpMessageList( + tester, + messages: messages, + unreadCount: 0, + currentUserRead: Read(user: ownUser, lastRead: DateTime.now(), unreadMessages: 0), + ); + + await tester.drag(find.byType(StreamMessageListView), const Offset(0, 400)); + await tester.pumpAndSettle(); + + final filtered = build(other); + await deliverMessageNew(tester, newMessage: filtered, existing: messages); + + expect(badgeLabel(tester), isNull); + + // Control: an ordinary message, delivered the same way, does bump the + // badge. Without this the assertion above would also hold if the + // new-message pipeline were simply inert. Sent by a third user so the + // muted-sender case's control isn't filtered out too. + await deliverMessageNew( + tester, + newMessage: Message( + id: 'control-counted', + text: 'Ordinary arrival', + user: User(id: 'controlid'), + createdAt: DateTime.now(), + ), + existing: [...messages, filtered], + ); + + expect(badgeLabel(tester), '1'); + } + + testWidgets('silent messages do not count', (tester) async { + await expectNotCounted( + tester, + build: (other) => Message( + id: 'silent-message', + text: 'Silent', + user: other, + silent: true, + createdAt: DateTime.now(), + ), + ); + }); + + testWidgets('shadowed messages do not count', (tester) async { + await expectNotCounted( + tester, + build: (other) => Message( + id: 'shadowed-message', + text: 'Shadowed', + user: other, + shadowed: true, + createdAt: DateTime.now(), + ), + ); + }); + + testWidgets('ephemeral messages do not count', (tester) async { + await expectNotCounted( + tester, + build: (other) => Message( + id: 'ephemeral-message', + text: 'Ephemeral', + user: other, + type: MessageType.ephemeral, + createdAt: DateTime.now(), + ), + ); + }); + + testWidgets('thread replies not also sent to the channel do not count', (tester) async { + await expectNotCounted( + tester, + build: (other) => Message( + id: 'thread-only-reply', + text: 'Thread only', + user: other, + parentId: 'some-parent', + createdAt: DateTime.now(), + ), + ); + }); + + testWidgets('messages from a muted user do not count', (tester) async { + final other = User(id: 'otherid'); + await expectNotCounted( + tester, + overrideOwnUser: OwnUser( + id: 'ownid', + mutes: [Mute(user: ownUser, target: other, createdAt: DateTime.now(), updatedAt: DateTime.now())], + ), + build: (_) => Message( + id: 'from-muted-user', + text: 'From muted', + user: other, + createdAt: DateTime.now(), + ), + ); + }); + + testWidgets( + 'a message arriving mid-drag still counts towards the badge', + (tester) async { + // Regression: the "don't fight a scroll already in motion" guard used + // to sit above the counting, so anything landing while the user was + // dragging or flinging was dropped from both counters for good. + final other = User(id: 'otherid'); + final messages = generateConversation(40, users: [other]).reversed.toList(); + + await pumpMessageList( + tester, + messages: messages, + unreadCount: 0, + currentUserRead: Read(user: ownUser, lastRead: DateTime.now(), unreadMessages: 0), + ); + + // Hold a drag open so the underlying ScrollPosition reports + // isScrolling == true while the message lands. + final gesture = await tester.startGesture( + tester.getCenter(find.byType(StreamMessageListView)), + ); + // Stepped moves with a frame between each, so the list actually + // scrolls and the view registers as away from the bottom. The + // pointer stays down throughout, so the underlying ScrollPosition + // keeps reporting isScrolling == true. + for (var i = 0; i < 8; i++) { + await gesture.moveBy(const Offset(0, 50)); + await tester.pump(); + } + + final midDrag = Message( + id: 'arrived-mid-drag', + text: 'Landed while dragging', + user: other, + createdAt: DateTime.now(), + ); + await deliverMessageNew(tester, newMessage: midDrag, existing: messages); + + expect(badgeLabel(tester), '1'); + + await gesture.up(); + await tester.pumpAndSettle(); + }, + ); + }); + group('scroll-to-bottom badge', () { testWidgets( 'appears only once the user is scrolled away from the bottom, and skips own messages', diff --git a/packages/stream_chat_flutter/test/src/message_list_view/unread_indicator_button_test.dart b/packages/stream_chat_flutter/test/src/message_list_view/unread_indicator_button_test.dart new file mode 100644 index 0000000000..b413c960f9 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/message_list_view/unread_indicator_button_test.dart @@ -0,0 +1,132 @@ +// Tests for [UnreadIndicatorButton]'s two modes. +// +// - Legacy (no `unreadCount`): the widget subscribes to the current user's +// read state itself, hides while there is nothing unread, and reports the +// boundary's `lastReadMessageId` to `onJumpTap`. This is the pre-existing +// public contract and must keep working for hosts outside the SDK. +// - Host-driven (`unreadCount` supplied): purely presentational — renders +// unconditionally with the given count and never touches read state. + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import '../mocks.dart'; + +void main() { + late StreamChatClient client; + late Channel channel; + late ChannelClientState channelClientState; + late ClientState clientState; + late OwnUser ownUser; + late StreamController currentUserReadController; + + setUp(() { + client = MockClient(); + clientState = MockClientState(); + when(() => client.state).thenAnswer((_) => clientState); + ownUser = OwnUser(id: 'ownid'); + when(() => clientState.currentUser).thenReturn(ownUser); + when(() => clientState.currentUserStream).thenAnswer((_) => Stream.value(ownUser)); + + currentUserReadController = StreamController.broadcast(); + addTearDown(currentUserReadController.close); + + channel = MockChannel(); + channelClientState = MockChannelState(); + when(() => channel.client).thenReturn(client); + when(() => channel.state).thenReturn(channelClientState); + when(() => channelClientState.currentUserReadStream).thenAnswer((_) => currentUserReadController.stream); + }); + + Future pumpButton( + WidgetTester tester, { + required Future Function(String?) onJumpTap, + int? unreadCount, + }) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StreamChat( + client: client, + themeData: StreamChatThemeData(), + child: StreamChannel.value( + channel: channel, + child: UnreadIndicatorButton( + unreadCount: unreadCount, + onJumpTap: onJumpTap, + onDismissTap: () async {}, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + group('legacy mode (no unreadCount)', () { + testWidgets('hides itself while there is nothing unread', (tester) async { + when(() => channelClientState.currentUserRead).thenReturn( + Read(user: ownUser, lastRead: DateTime.now(), unreadMessages: 0), + ); + + await pumpButton(tester, onJumpTap: (_) async {}); + + expect(find.byType(StreamJumpToUnreadButton), findsNothing); + }); + + testWidgets('shows itself and reports lastReadMessageId when there is unread', (tester) async { + when(() => channelClientState.currentUserRead).thenReturn( + Read( + user: ownUser, + lastRead: DateTime.now(), + unreadMessages: 7, + lastReadMessageId: 'boundary-id', + ), + ); + + String? received; + var called = false; + await pumpButton( + tester, + onJumpTap: (id) async { + received = id; + called = true; + }, + ); + + expect(find.byType(StreamJumpToUnreadButton), findsOneWidget); + + final button = tester.widget(find.byType(UnreadIndicatorButton)); + await button.onJumpTap( + channelClientState.currentUserRead!.lastReadMessageId, + ); + + expect(called, isTrue); + expect(received, 'boundary-id'); + }); + }); + + group('host-driven mode (unreadCount supplied)', () { + testWidgets('renders with the supplied count without reading channel state', (tester) async { + // Deliberately no `currentUserRead` stub: touching it would throw, so + // this also proves the widget never consults read state in this mode. + await pumpButton(tester, onJumpTap: (_) async {}, unreadCount: 3); + + expect(find.byType(StreamJumpToUnreadButton), findsOneWidget); + verifyNever(() => channelClientState.currentUserRead); + }); + + testWidgets('renders even when the supplied count is zero', (tester) async { + // Visibility belongs to the host in this mode, so the widget must not + // second-guess it. + await pumpButton(tester, onJumpTap: (_) async {}, unreadCount: 0); + + expect(find.byType(StreamJumpToUnreadButton), findsOneWidget); + }); + }); +} diff --git a/packages/stream_chat_localizations/CHANGELOG.md b/packages/stream_chat_localizations/CHANGELOG.md index 2c6bc6bc56..19d1e8581e 100644 --- a/packages/stream_chat_localizations/CHANGELOG.md +++ b/packages/stream_chat_localizations/CHANGELOG.md @@ -3,7 +3,10 @@ ✅ Added - Added connection-error translations (`connectionErrorTitle`/`Description`, `slowConnectionErrorTitle`/`Description`, `genericErrorTitle`/`Description`) for all supported locales. -- Added `unreadMessagesSeparatorLabel` for all supported locales. +- Added `unreadMessagesSeparatorLabel` for all supported locales, showing a count (e.g. "5 unread messages"). + `GlobalStreamChatLocalizations` provides a default implementation that falls back to the deprecated + count-less `unreadMessagesSeparatorText`, so localization subclasses written before this method existed + keep compiling and keep showing any custom text they already override. ## 10.2.0 diff --git a/packages/stream_chat_localizations/example/lib/add_new_lang.dart b/packages/stream_chat_localizations/example/lib/add_new_lang.dart index bf0edb489b..a292a97efa 100644 --- a/packages/stream_chat_localizations/example/lib/add_new_lang.dart +++ b/packages/stream_chat_localizations/example/lib/add_new_lang.dart @@ -508,6 +508,7 @@ class NnStreamChatLocalizations extends GlobalStreamChatLocalizations { String get viewLibrary => 'View library'; @override + // ignore: deprecated_member_use String unreadMessagesSeparatorText() => 'New messages'; @override diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations.dart index ab65cd486a..45e480eda0 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations.dart @@ -122,6 +122,18 @@ abstract class GlobalStreamChatLocalizations implements StreamChatLocalizations /// (e.g. `'en'`, `'de'`, `'fr'`). final String localeName; + /// The label for the unread messages separator, e.g. "5 unread messages". + /// + /// Defaults to the count-less `unreadMessagesSeparatorText` so subclasses + /// written before this method existed keep compiling, and any custom text + /// they already override keeps being shown. The bundled locales override + /// this to include the count. + @override + String unreadMessagesSeparatorLabel({required int count}) { + // ignore: deprecated_member_use + return unreadMessagesSeparatorText(); + } + /// A [LocalizationsDelegate] for [StreamChatLocalizations]. /// /// Most internationalized apps will use diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart index abedc50ec2..96bcbcc620 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart @@ -493,6 +493,7 @@ class StreamChatLocalizationsCa extends GlobalStreamChatLocalizations { String get linkDisabledError => 'Els enllaços estan deshabilitats'; @override + // ignore: deprecated_member_use String unreadMessagesSeparatorText() => 'Missatges nous'; @override diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart index dac3b8249b..47473089d7 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart @@ -490,6 +490,7 @@ class StreamChatLocalizationsDe extends GlobalStreamChatLocalizations { String get viewLibrary => 'Bibliothek öffnen'; @override + // ignore: deprecated_member_use String unreadMessagesSeparatorText() => 'Neue Nachrichten'; @override diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart index 9c925ea9f3..18787fa8d2 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart @@ -492,6 +492,7 @@ class StreamChatLocalizationsEn extends GlobalStreamChatLocalizations { String get viewLibrary => 'View library'; @override + // ignore: deprecated_member_use String unreadMessagesSeparatorText() => 'New messages'; @override diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart index bac21258e0..9948b0565f 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart @@ -496,6 +496,7 @@ No es posible añadir más de $limit archivos adjuntos String get linkDisabledError => 'Los enlaces están deshabilitados'; @override + // ignore: deprecated_member_use String unreadMessagesSeparatorText() => 'Nuevos mensajes'; @override diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart index 35f2e1af68..5fcfb11c5d 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart @@ -496,6 +496,7 @@ Limite de pièces jointes dépassée : il n'est pas possible d'ajouter plus de $ String get linkDisabledError => 'Les liens sont désactivés'; @override + // ignore: deprecated_member_use String unreadMessagesSeparatorText() => 'Nouveaux messages'; @override diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart index d6ae8a2fdb..cdfc4829c3 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart @@ -494,6 +494,7 @@ class StreamChatLocalizationsHi extends GlobalStreamChatLocalizations { String get linkDisabledError => 'लिंक भेजना प्रतिबंधित'; @override + // ignore: deprecated_member_use String unreadMessagesSeparatorText() => 'नए संदेश।'; @override diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart index 6986d8c5e4..2dc812419d 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart @@ -499,6 +499,7 @@ Attenzione: il limite massimo di $limit file è stato superato. String get linkDisabledError => 'I links sono disattivati'; @override + // ignore: deprecated_member_use String unreadMessagesSeparatorText() => 'Nuovi messaggi'; @override diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart index 0dafeeefb0..95198e0169 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart @@ -482,6 +482,7 @@ class StreamChatLocalizationsJa extends GlobalStreamChatLocalizations { String get linkDisabledError => 'リンクが無効になっています'; @override + // ignore: deprecated_member_use String unreadMessagesSeparatorText() => '新しいメッセージ。'; @override diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart index 4c06b62894..c895b5b57e 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart @@ -483,6 +483,7 @@ class StreamChatLocalizationsKo extends GlobalStreamChatLocalizations { String get linkDisabledError => '링크가 비활성화되었습니다.'; @override + // ignore: deprecated_member_use String unreadMessagesSeparatorText() => '새 메시지.'; @override diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart index 6141413485..c2f78dcc56 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart @@ -434,6 +434,7 @@ class StreamChatLocalizationsNo extends GlobalStreamChatLocalizations { String get viewLibrary => 'Se bibliotek'; @override + // ignore: deprecated_member_use String unreadMessagesSeparatorText() => 'Nye meldinger.'; @override diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart index 23e2c78e4f..d61d0957fc 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart @@ -495,6 +495,7 @@ Não é possível adicionar mais de $limit arquivos de uma vez String get viewLibrary => 'Ver biblioteca'; @override + // ignore: deprecated_member_use String unreadMessagesSeparatorText() => 'Novas mensagens'; @override diff --git a/packages/stream_chat_localizations/test/translations_test.dart b/packages/stream_chat_localizations/test/translations_test.dart index 9200af68e6..da63015182 100644 --- a/packages/stream_chat_localizations/test/translations_test.dart +++ b/packages/stream_chat_localizations/test/translations_test.dart @@ -225,6 +225,9 @@ void main() { expect(localizations.toggleBlockUnblockUserText(isBlocked: true), isNotNull); expect(localizations.toggleBlockUnblockUserText(isBlocked: false), isNotNull); expect(localizations.viewLibrary, isNotNull); + // Still asserted after deprecation: it remains the fallback for + // translation classes that haven't overridden the count-based label. + // ignore: deprecated_member_use expect(localizations.unreadMessagesSeparatorText(), isNotNull); expect(localizations.enableFileAccessMessage, isNotNull); expect(localizations.allowFileAccessMessage, isNotNull); From 2accaa1db9e2104dd53e61e3eede5bcdffabf3f2 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Thu, 20 Aug 2026 14:30:53 +0200 Subject: [PATCH 09/14] fix(ui, core, localization): address review findings on unread banners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the issues found by a two-reviewer pass over the PR. Blockers: - A second mark-unread while one was already active could not move the divider or the pill. The reset was gated on the `isMarkedAsUnread` transition, but that flag is only ever cleared by a mark-read, so the second action was silently swallowed. It now watches the read boundary (`lastRead` / `lastReadMessageId`), which still ignores plain arrivals. - A channel the user has never opened could never auto-mark-read again: its anchor only resolves once top pagination reaches the start of the channel. Channels with no boundary to reach are now exempt from that gate. Also: - Seed the marked-unread state from the channel on attach, so the `BehaviorSubject` replay isn't misread as a fresh mark-unread, and never snapshot an un-laid-out viewport — together these restore the mark-unread viewport guard and make its documented fallback reachable. - Don't paint the jump-to-unread pill before item positions are known; it used to flash for a frame on every channel opened at its first unread. - Honour the user-level `isReadReceiptsEnabled` in the badge/divider counting rule, matching `MessageRules.canCountAsUnread`. - Guard the pill's jump against a channel change during its awaited pagination, de-dupe mark-read attempts so a failing one isn't retried on every scroll tick, and stop the dismiss tap leaking a rejected future. - Scope the `unreadMessagesSeparatorLabel` compatibility claim to `extends`/`with` in the dartdoc and CHANGELOGs — `implements` does not inherit the fallback body — and render the new plurals through `Intl.plural`, matching the wording of the a11y sibling string. - Drop no-op `// ignore: deprecated_member_use` comments on overrides, and ticket ids from shipped comments. Tests: the two blockers and the mark-unread-on-mount case now have regression tests; the `onJumpTap` test taps the widget instead of calling the callback; the locale test asserts the count is rendered instead of `isNotNull` on a non-nullable String; adds coverage for `openAtFirstUnread` in both modes, the pill retiring at the boundary, no separator for messages arriving while open, and the restricted / read-receipts-off filters. Co-Authored-By: Claude Opus 5 --- packages/stream_chat_flutter/CHANGELOG.md | 11 +- .../lib/src/localization/translations.dart | 21 +- .../message_list_view/message_list_view.dart | 195 ++++++++++++++---- .../src/message_list_view/mark_read_test.dart | 184 +++++++++++++++-- .../unread_divider_test.dart | 155 +++++++++++++- .../unread_indicator_button_test.dart | 15 +- .../test/stream_channel_test.dart | 71 ++++++- .../stream_chat_localizations/CHANGELOG.md | 5 +- .../example/lib/add_new_lang.dart | 1 - .../lib/src/stream_chat_localizations.dart | 13 +- .../lib/src/stream_chat_localizations_ca.dart | 9 +- .../lib/src/stream_chat_localizations_de.dart | 9 +- .../lib/src/stream_chat_localizations_en.dart | 9 +- .../lib/src/stream_chat_localizations_es.dart | 9 +- .../lib/src/stream_chat_localizations_fr.dart | 9 +- .../lib/src/stream_chat_localizations_hi.dart | 9 +- .../lib/src/stream_chat_localizations_it.dart | 9 +- .../lib/src/stream_chat_localizations_ja.dart | 8 +- .../lib/src/stream_chat_localizations_ko.dart | 8 +- .../lib/src/stream_chat_localizations_no.dart | 9 +- .../lib/src/stream_chat_localizations_pt.dart | 9 +- .../test/translations_test.dart | 14 +- 22 files changed, 665 insertions(+), 117 deletions(-) diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index ccf0501847..5e45074a7a 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -8,17 +8,18 @@ - Added a `size` (`StreamLoadingSpinnerSize`) parameter to `StreamScrollViewLoadingWidget`. - Added `onReactionTap` to `StreamMessageItem` and `StreamMessageListView`, reporting the tapped message's `BuildContext` and a `ReactionTapDetails` with the tapped `message` and `reaction` (the reaction is `null` for a clustered or overflow chip that maps to no single reaction). - Added an `unreadIndicator` parameter to `StreamBackButton` that overlays a widget (typically a `StreamUnreadIndicator`) on the button's top-end corner. Pass `StreamUnreadIndicator(excludeCid: cid)` to show the total unread count of other channels, or `StreamUnreadIndicator.channels(cid: cid)` for a single channel's count. -- Added `Channel.isMarkedAsUnread` (via `ChannelClientState`), reporting whether the current user has an active manual mark-unread that hasn't been read past yet. - Added `StreamChannel.openAtFirstUnread` (`stream_chat_flutter_core`), defaulting to `true`. Set to `false` to always open a channel at the latest message instead of scrolling to the first pre-existing unread message. -- Added `Translations.unreadMessagesSeparatorLabel`, used by the default `UnreadMessagesSeparator` to show a count, e.g. "5 unread messages". It has a default implementation that falls back to the (now deprecated) `unreadMessagesSeparatorText`, so existing translation classes keep compiling and any custom text they already override keeps being shown. +- Added `Translations.unreadMessagesSeparatorLabel`, used by the default `UnreadMessagesSeparator` to show a count, e.g. "5 unread messages". It falls back to the (now deprecated) `unreadMessagesSeparatorText`, so a class that extends `Translations` keeps showing any custom text it already overrides. +- Exported `UnreadMessagesSeparator`, the divider widget `StreamMessageListView` renders at the unread boundary. - Added an optional `unreadCount` to `UnreadIndicatorButton`. When supplied, the widget renders unconditionally with that count and skips its internal read-state subscription, letting the host own visibility — this is how `StreamMessageListView` now drives it. Omitting it keeps the previous self-subscribing behaviour, and `onJumpTap` keeps its `String? lastReadMessageId` argument, so existing usages are unaffected. 🔄 Changed -- Changed the "↑ N unread" jump-to-unread pill to a count fixed when the channel opens, staying on screen for the whole session rather than reacting to the live, shrinking unread count. The pill now shows as soon as that count is known — even before the boundary message itself has loaded — and dismisses permanently for the session once tapped, dismissed, or scrolled past; it no longer reappears when a new message arrives. +- `Translations.unreadMessagesSeparatorLabel` is a new interface member. Classes that `extends Translations` (or `GlobalStreamChatLocalizations`) inherit the fallback and need no change, but a class that `implements` either interface directly must add this member — Dart does not inherit method bodies through `implements`. Forward it to your existing `unreadMessagesSeparatorText()` to keep the previous copy. +- Changed the "↑ N unread" jump-to-unread pill to a count fixed when the channel opens, shown as soon as that count is known and dismissed permanently for the session once tapped, dismissed, or scrolled past. - Changed the scroll-to-bottom badge to count only messages that arrive out of view during the current session, rather than being seeded from the channel's unread count. It always resets to 0 once the user reaches the bottom. - Changed the "unread messages" divider to show a count, starting at the channel's open-time unread total and counting up as further messages arrive during the session — mirroring WhatsApp — instead of a fixed, count-less label. -- Tightened `StreamMessageListView`'s automatic mark-read gating to also require that the pre-existing unread boundary (if any) has been seen or scrolled past, and that there's no pending manual mark-unread. Previously, reaching the bottom with unread messages present was sufficient. +- Tightened `StreamMessageListView`'s automatic mark-read gating to also require that the pre-existing unread boundary has been seen or scrolled past, and that there's no pending manual mark-unread. Channels with no boundary to reach — opened fully read, never opened at all, or tracking unread locally — are unaffected. ⚠️ Deprecated @@ -36,7 +37,7 @@ - Fixed the attachment picker throwing a `Tooltip` assertion error when a custom `TabbedAttachmentPickerOption` is added without a `title`; the tooltip is now only shown when a title is provided. - Fixed the `StreamBackButton` unread badge including the currently open channel in its total count. - Fixed messages arriving while the user was mid-drag or mid-fling being dropped from the scroll-to-bottom badge and the unread divider's count. The "don't fight a scroll in motion" guard ran before the counting, so those arrivals were never counted at all. -- Fixed the scroll-to-bottom badge and unread divider counting messages the channel's own unread count ignores — silent, shadowed, ephemeral, thread-only, restricted, and muted-sender messages no longer inflate either counter. +- Fixed the scroll-to-bottom badge and unread divider counting messages the channel's own unread count ignores — silent, shadowed, ephemeral, thread-only, restricted, own and muted-sender messages no longer inflate either counter, and neither counts at all while the user has read receipts disabled. - Fixed thread reads being blocked whenever the parent channel wasn't up to date. `markThreadRead` no longer consults the channel's `isUpToDate`, which is unrelated to a thread's own read state. - Fixed the jump-to-unread pill being dismissed by the slightest scroll after marking a message unread. Its anchor is the message the user just acted on, so it starts out on screen; only scrolling past it now retires the pill. - Fixed the jump-to-unread pill flickering back in and straight out on every new message after being dismissed. The mark-unread reset now runs on the transition into the marked-unread state rather than on every read-state emission while it is set. diff --git a/packages/stream_chat_flutter/lib/src/localization/translations.dart b/packages/stream_chat_flutter/lib/src/localization/translations.dart index cb2a2d343c..66ee364248 100644 --- a/packages/stream_chat_flutter/lib/src/localization/translations.dart +++ b/packages/stream_chat_flutter/lib/src/localization/translations.dart @@ -1,5 +1,6 @@ // ignore_for_file: lines_longer_than_80_chars +import 'package:intl/intl.dart'; import 'package:jiffy/jiffy.dart'; import 'package:stream_chat_flutter/src/localization/accessibility_translations.dart'; import 'package:stream_chat_flutter/src/message_list_view/message_list_view.dart'; @@ -106,11 +107,16 @@ abstract class Translations { /// The label for the unread messages separator in the /// [StreamMessageListView], e.g. "5 unread messages". /// - /// Defaults to the count-less `unreadMessagesSeparatorText` so that - /// implementations written before this method existed — including ones - /// that customise only that older string — keep rendering their own text + /// Falls back to the count-less `unreadMessagesSeparatorText`, so an + /// implementation written before this method existed — including one that + /// customises only that older string — keeps rendering its own text /// rather than silently reverting to the built-in copy. Override this to /// show the count. + /// + /// Note that the fallback only helps classes that `extends` (or mix in) + /// [Translations]: Dart does not inherit method bodies through + /// `implements`, so a class implementing this interface directly has to + /// add this member. See the CHANGELOG for the migration. String unreadMessagesSeparatorLabel({required int count}) { // ignore: deprecated_member_use_from_same_package return unreadMessagesSeparatorText(); @@ -1302,13 +1308,16 @@ Attachment limit exceeded: it's not possible to add more than $limit attachments String get linkDisabledError => 'Links are disabled'; @override - // ignore: deprecated_member_use_from_same_package String unreadMessagesSeparatorText() => 'New messages'; @override String unreadMessagesSeparatorLabel({required int count}) { - if (count == 1) return '1 unread message'; - return '$count unread messages'; + return Intl.plural( + count, + one: '$count unread message', + other: '$count unread messages', + locale: 'en', + ); } @override diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart index f78b7c4da3..55c27cfe60 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart @@ -323,13 +323,22 @@ class _StreamMessageListViewState extends State { // [_maybeMarkMessagesAsRead]) gates auto mark-read. final ValueNotifier _hasSeenFirstUnread = ValueNotifier(false); + // Whether the list has reported item positions at least once. + // + // The pill waits on this. Its count is published synchronously while the + // channel is attached, but `_hasSeenFirstUnread` can only be decided from + // item positions, which arrive in a post-frame callback — so without this + // the pill paints for exactly one frame on every channel opened at its + // first unread message, then disappears. + final ValueNotifier _hasLaidOut = ValueNotifier(false); + // Scroll-to-bottom badge count. Counts messages that arrive while the user // is scrolled away from the bottom; resets to 0 once they reach the bottom. final ValueNotifier _scrollToBottomBadge = ValueNotifier(0); - // Sticky "bottom was reached" flag for the FLU-640 mark-read gate. Cleared - // after each successful mark-read so returning to the bottom is required - // again before the next one. + // Sticky "bottom was reached" flag for the mark-read gate. Cleared after + // each successful mark-read so returning to the bottom is required again + // before the next one. bool _hasSeenLastMessage = false; // While non-null, the viewport captured at the moment an active manual @@ -375,12 +384,32 @@ class _StreamMessageListViewState extends State { // mark-read that should already have been earned by that round trip. bool _markUnreadViewportDiverged = false; + // State the last mark-read attempt was made against. Item positions tick + // on every scroll frame, so without this a mark-read that keeps failing + // would be retried for as long as the user keeps scrolling at the bottom + // (once a second, as bounded by the debounce). Every input the gate in + // [_maybeMarkMessagesAsRead] actually reads is part of the key, so a + // genuine change — a new message, a mark-unread, the viewport diverging + // after one — still gets its attempt. + ({String? newestMessageId, int unreadCount, bool isMarkedAsUnread, bool viewportDiverged})? _lastMarkReadAttempt; + // Previous value of `channel.state.isMarkedAsUnread`, so - // [_handleCurrentUserReadChanged] can act on the *transition* into the - // marked-unread state rather than on every read-stream emission that - // happens while it's set. + // [_handleCurrentUserReadChanged] can act on a new mark-unread rather than + // on every read-stream emission that happens while the flag stays set. + // Seeded from the channel on attach, since it can already be set there. bool _wasMarkedAsUnread = false; + // Read boundary observed alongside [_wasMarkedAsUnread]. A mark-unread + // moves the boundary backward, so a change here while the flag is already + // set is how a *second* mark-unread is told apart from the read-stream + // emissions that keep arriving during one. + ({DateTime? lastRead, String? lastReadMessageId})? _lastReadBoundary; + + static ({DateTime? lastRead, String? lastReadMessageId})? _readBoundaryOf(Read? read) { + if (read == null) return null; + return (lastRead: read.lastRead, lastReadMessageId: read.lastReadMessageId); + } + // Whether divider A's current session came from an explicit mark-unread // rather than from pre-existing unread at channel open. The anchor of a // manual mark-unread is the message the user was looking at when they @@ -429,11 +458,21 @@ class _StreamMessageListViewState extends State { // moves the read boundary backward — treat it as a new session start for // divider A/the pill. // - // The reset is deliberately gated on the *transition* into - // `isMarkedAsUnread`, not on the flag being set: the read stream also - // emits while it stays set (every new message, for one), and re-running - // the reset then would clear `_hasSeenFirstUnread` again and flicker the - // pill back in and straight out on each arrival. + // The reset is deliberately gated on a *new* mark-unread — the flag + // turning on, or the read boundary moving again while it's already on — + // rather than on the flag merely being set: the read stream also emits + // while it stays set (every new message, for one), and re-running the + // reset then would clear `_hasSeenFirstUnread` again and flicker the pill + // back in and straight out on each arrival. An arriving message bumps + // `unreadMessages` but leaves the boundary untouched, so gating on the + // boundary avoids that flicker while still catching a second mark-unread. + // + // Watching the boundary rather than only the transition matters because + // `isMarkedAsUnread` is cleared solely by a mark-read (see + // `ChannelClientState.markReadLocally`), and both + // [_captureUnreadBaselineIfNeeded] and [_resolveUnreadDivider] freeze once + // resolved — so this reset is the only thing that can move divider A once + // a mark-unread session is under way. void _handleCurrentUserReadChanged() { if (_isThreadConversation) return; @@ -441,8 +480,11 @@ class _StreamMessageListViewState extends State { if (channel == null) return; final isMarkedAsUnread = channel.state?.isMarkedAsUnread ?? false; - final justMarkedAsUnread = isMarkedAsUnread && !_wasMarkedAsUnread; + final readBoundary = _readBoundaryOf(channel.state?.currentUserRead); + final boundaryMoved = readBoundary != _lastReadBoundary; + final justMarkedAsUnread = isMarkedAsUnread && (!_wasMarkedAsUnread || boundaryMoved); _wasMarkedAsUnread = isMarkedAsUnread; + _lastReadBoundary = readBoundary; if (justMarkedAsUnread) { _unreadBaselineCaptured = false; @@ -451,11 +493,16 @@ class _StreamMessageListViewState extends State { _unreadDividerGrowth.value = 0; _hasSeenFirstUnread.value = false; _unreadFromManualMarkUnread = true; - // Only capture once per mark-unread session — a later, unrelated - // read-stream emission while still marked unread shouldn't keep - // chasing the latest position and never let a genuine scroll differ - // from it. - _markUnreadViewportSnapshot ??= _itemPositionListener.itemPositions.value.map((it) => it.index).toList(); + // Each mark-unread starts its own session, so the snapshot is taken + // fresh here rather than kept from a previous one — but only from a + // viewport that has actually been laid out. `itemPositions` is still + // empty before the first frame, and capturing that would make the + // very first laid-out frame look like divergence, defeating guard 4 + // and leaving the fallback in [_handleItemPositionsChanged] + // unreachable. Left null instead, for that fallback to fill in. + final visibleIndices = _itemPositionListener.itemPositions.value.map((it) => it.index).toList(); + _markUnreadViewportSnapshot = visibleIndices.isEmpty ? null : visibleIndices; + _markUnreadViewportDiverged = false; } else if (!isMarkedAsUnread) { _unreadFromManualMarkUnread = false; _markUnreadViewportSnapshot = null; @@ -519,6 +566,8 @@ class _StreamMessageListViewState extends State { debouncedMarkRead.cancel(); debouncedMarkThreadRead.cancel(); + final newChannelState = newStreamChannel.channel.state; + _unreadBaselineCaptured = false; _unreadBaseline = null; _unreadDivider.value = (count: 0, anchorId: null); @@ -526,10 +575,21 @@ class _StreamMessageListViewState extends State { _scrollToBottomBadge.value = 0; _hasSeenFirstUnread.value = false; _hasSeenLastMessage = false; + _showScrollToBottom.value = false; + _hasLaidOut.value = false; + _lastMarkReadAttempt = null; _markUnreadViewportSnapshot = null; _markUnreadViewportDiverged = false; - _wasMarkedAsUnread = false; - _unreadFromManualMarkUnread = false; + // Seeded from the channel's own state rather than hardcoded to false: + // a channel can mount with a manual mark-unread already active (mark + // unread, leave, come back — the flag lives on the cached + // `ChannelClientState`). `currentUserReadStream` is backed by a + // `BehaviorSubject`, so the subscription below replays the current + // value straight away; without this seed that replay would read as a + // brand-new mark-unread and restart a session that never ended. + _wasMarkedAsUnread = newChannelState?.isMarkedAsUnread ?? false; + _lastReadBoundary = _readBoundaryOf(newChannelState?.currentUserRead); + _unreadFromManualMarkUnread = _wasMarkedAsUnread; _captureUnreadBaselineIfNeeded(); final highlightInitialMessage = widget.config.highlightInitialMessage; @@ -546,7 +606,7 @@ class _StreamMessageListViewState extends State { }); } - final state = streamChannel?.channel.state; + final state = newChannelState; final newMessageStream = switch (widget.parentMessage?.id) { final parentId? => state?.newThreadMessageStream(parentId), _ => state?.newMessageStream, @@ -629,6 +689,7 @@ class _StreamMessageListViewState extends State { _hasSeenFirstUnread.dispose(); _scrollToBottomBadge.dispose(); _showScrollToBottom.dispose(); + _hasLaidOut.dispose(); _highlightState.dispose(); super.dispose(); } @@ -1039,13 +1100,24 @@ class _StreamMessageListViewState extends State { // scrolled most of the way there themselves. if (unread.count <= 0) return const Empty(); return ValueListenableBuilder( - valueListenable: _hasSeenFirstUnread, - builder: (context, seen, __) { - if (seen) return const Empty(); - return UnreadIndicatorButton( - unreadCount: unread.count, - onJumpTap: (_) => _onUnreadPillJumpTap(), - onDismissTap: _onUnreadPillDismissTap, + valueListenable: _hasLaidOut, + builder: (context, laidOut, ___) { + // Item positions decide whether the boundary is already + // on screen, and they only arrive after the first frame + // is laid out. Painting before then would flash the pill + // for a frame on every channel opened at its first + // unread message — which is the default. + if (!laidOut) return const Empty(); + return ValueListenableBuilder( + valueListenable: _hasSeenFirstUnread, + builder: (context, seen, __) { + if (seen) return const Empty(); + return UnreadIndicatorButton( + unreadCount: unread.count, + onJumpTap: (_) => _onUnreadPillJumpTap(), + onDismissTap: _onUnreadPillDismissTap, + ); + }, ); }, ); @@ -1171,17 +1243,25 @@ class _StreamMessageListViewState extends State { // [StreamChannelState.loadChannelAtMessage] when the anchor isn't in the // currently loaded window — after which the real anchor resolves // naturally via the retry in [_buildListView], rendering divider A too. + // `_scrollToMessage` can await pagination and a frame, and this same + // `State` survives a channel change — so remember which channel the tap + // was for and drop the result if it isn't the current one any more. + final tappedFor = streamChannel; final didJump = await _scrollToMessage(messageId: anchorId, highlight: false); + if (!mounted || streamChannel != tappedFor) return; // Only claim the boundary as seen once the jump actually landed — // otherwise (message not found even after pagination, or the SPL not // attached) the pill would vanish and the mark-read gate would open for // a boundary the user never actually reached. - if (didJump && mounted) _hasSeenFirstUnread.value = true; + if (didJump) _hasSeenFirstUnread.value = true; } Future _onUnreadPillDismissTap() async { _hasSeenFirstUnread.value = true; - await _markMessagesAsRead(); + // Dismissing is a local decision; if the request behind it fails there + // is nothing to show the user, and letting it escape here would surface + // as an unhandled async error instead. + _markMessagesAsRead().ignore(); } late final debouncedMarkRead = debounce( @@ -1463,8 +1543,14 @@ class _StreamMessageListViewState extends State { // `usesLocalUnreadCount` is an extension getter reading `Channel`'s // private client field, so it can't be resolved against a channel double // at all. + // + // The user-level half (`isReadReceiptsEnabled`) is *not* left out: it + // isn't about how the channel is configured but about the user opting out + // of unread tracking entirely, and honouring it here is what keeps these + // indicators from counting up while the channel itself reports zero. bool _countsTowardsUnreadIndicators(Message message, OwnUser? currentUser) { if (currentUser == null) return false; + if (!currentUser.isReadReceiptsEnabled) return false; if (message.silent) return false; if (message.shadowed) return false; @@ -1487,9 +1573,13 @@ class _StreamMessageListViewState extends State { } void _handleItemPositionsChanged() { + if (!mounted) return; + final itemPositions = _itemPositionListener.itemPositions.value; if (itemPositions.isEmpty) return; + _hasLaidOut.value = true; + // Snapshot the viewport (or check it against an existing snapshot for // divergence) the first time it's genuinely laid out while marked as // unread, in case the channel simply mounted in that state rather than @@ -1521,16 +1611,14 @@ class _StreamMessageListViewState extends State { isLastItemFullyVisible = lastItemPosition.itemLeadingEdge >= 0; } - if (mounted) _showScrollToBottom.value = !isLastItemFullyVisible; + _showScrollToBottom.value = !isLastItemFullyVisible; if (isLastItemFullyVisible) { _hasSeenLastMessage = true; _scrollToBottomBadge.value = 0; } - // Attempt a mark-read whenever either half of the FLU-640 gate could - // have just become satisfied; `_maybeMarkMessagesAsRead` does the actual - // deciding, and the leading-edge debounce inside it makes repeated - // attempts cheap. + // Attempt a mark-read whenever either half of the gate could have just + // become satisfied; `_maybeMarkMessagesAsRead` does the actual deciding. if ((isLastItemFullyVisible || justSeenFirstUnread) && widget.config.markReadWhenAtTheBottom) { _maybeMarkMessagesAsRead().ignore(); } @@ -1590,9 +1678,12 @@ class _StreamMessageListViewState extends State { final visibleIndices = itemPositions.map((position) => position.index).toList(); if (visibleIndices.isEmpty) return false; - // Smaller item indices are newer/closer to the bottom. If even the - // newest visible item is older than the anchor, the anchor has scrolled - // off the bottom of the viewport — the user scrolled past it. + // Smaller item indices are newer. That is a property of the + // index-to-message mapping (`messages[i - 2]`, newest first), not of the + // scroll direction, so it holds regardless of `config.reverse`. If even + // the newest visible item is older than the anchor, the anchor is no + // longer in view and the user has scrolled back past it into read + // history. final isScrolledPast = visibleIndices.reduce(min) > anchorItemIndex; if (_unreadFromManualMarkUnread) { @@ -1630,10 +1721,13 @@ class _StreamMessageListViewState extends State { // message just marked, with nothing yet scrolled) would undo the // user's action instantly. // 5. Divider A's anchor has actually been seen or scrolled past - // (`hasSeenFirstUnreadMessage`) — trivially satisfied when there's - // nothing to see (the channel opened fully read) or for channels - // using local unread counts, which have no server read state to - // anchor against. + // (`hasSeenFirstUnreadMessage`) — trivially satisfied when there is + // no boundary to see in the first place: the channel opened fully + // read, the user has never opened it at all (no `lastReadMessageId`, + // so the anchor could only resolve once top pagination reached the + // very start of the channel — effectively never for real history), + // or the channel uses local unread counts and so has no server read + // state to anchor against. Future _maybeMarkMessagesAsRead() async { final channel = streamChannel?.channel; if (channel == null) return; @@ -1653,13 +1747,17 @@ class _StreamMessageListViewState extends State { final unreadCount = channel.state?.unreadCount ?? 0; if (unreadCount <= 0) return; - final noPreexistingUnread = _unreadBaselineCaptured && _unreadBaseline == null; + // True both when the channel opened fully read (no baseline) and when + // the user has never opened it (a baseline with no `lastReadMessageId`). + // Neither has a boundary the user could reach, so requiring one would + // leave the channel permanently unread — see condition 5 above. + final hasNoUnreadBoundary = _unreadBaselineCaptured && _unreadBaseline?.lastReadMessageId == null; // Equivalent to `channel.usesLocalUnreadCount`, spelled out via // `channel.client` rather than `Channel`'s private client field so it // stays evaluable against a test double that only implements the public // API surface. final usesLocalUnreadCount = channel.client.isLocalUnreadCountEnabled && !channel.canUseReadReceipts; - final hasSeenFirstUnreadMessage = noPreexistingUnread || _hasSeenFirstUnread.value || usesLocalUnreadCount; + final hasSeenFirstUnreadMessage = hasNoUnreadBoundary || _hasSeenFirstUnread.value || usesLocalUnreadCount; final isMarkedAsUnread = channel.state?.isMarkedAsUnread ?? false; final hasSeenLastMessage = _hasSeenLastMessage || !_showScrollToBottom.value; @@ -1677,6 +1775,17 @@ class _StreamMessageListViewState extends State { if (!_markUnreadViewportDiverged) return; } + // Everything the gate above reads is in the key, so an attempt is only + // skipped when repeating it could not produce a different outcome. + final attempt = ( + newestMessageId: messages.firstOrNull?.id, + unreadCount: unreadCount, + isMarkedAsUnread: isMarkedAsUnread, + viewportDiverged: _markUnreadViewportDiverged, + ); + if (attempt == _lastMarkReadAttempt) return; + _lastMarkReadAttempt = attempt; + await _debouncedMarkMessagesAsRead(); _hasSeenLastMessage = false; _markUnreadViewportSnapshot = null; diff --git a/packages/stream_chat_flutter/test/src/message_list_view/mark_read_test.dart b/packages/stream_chat_flutter/test/src/message_list_view/mark_read_test.dart index 57c231ece9..cf271ee20a 100644 --- a/packages/stream_chat_flutter/test/src/message_list_view/mark_read_test.dart +++ b/packages/stream_chat_flutter/test/src/message_list_view/mark_read_test.dart @@ -1,7 +1,7 @@ // Tests for `StreamMessageListView`'s mark-read-at-the-bottom behavior. // // The logic lives in `_handleItemPositionsChanged` → -// `_maybeMarkMessagesAsRead` (FLU-640). Marking the channel read requires all +// `_maybeMarkMessagesAsRead`. Marking the channel read requires all // of: // // 1. `markReadWhenAtTheBottom` is true (the default). @@ -27,6 +27,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:rxdart/rxdart.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import '../../test_utils/data_generator.dart'; @@ -99,7 +100,7 @@ void main() { ).thenAnswer((_) async => QueryRepliesResponse()..messages = []); }); - // Default: opened with nothing pre-existing unread, so the FLU-640 + // Default: opened with nothing pre-existing unread, so the // "has seen the first unread boundary" condition is trivially satisfied // and doesn't gate these tests unless a `currentUserRead` override says // otherwise. @@ -186,6 +187,33 @@ void main() { }, ); + testWidgets( + 'fires on a channel the user has never opened, whose boundary can ' + 'never resolve', + (tester) async { + // No `lastReadMessageId` at all, and top pagination hasn't reached + // the start of the channel, so the unread anchor can never resolve. + // Requiring the boundary to have been seen would leave a channel + // like this permanently unread. + final other = User(id: 'otherid'); + final messages = generateConversation(20, users: [other]).reversed.toList(); + + await pumpMessageList( + tester, + messages: messages, + isUpToDate: true, + unreadCount: 5, + currentUserRead: Read( + user: ownUser, + lastRead: DateTime.now().subtract(const Duration(days: 1)), + unreadMessages: 5, + ), + ); + + verify(() => channel.markRead(messageId: any(named: 'messageId'))).called(1); + }, + ); + testWidgets( 'does NOT fire when isUpToDate=false (gate on incomplete state)', (tester) async { @@ -246,7 +274,7 @@ void main() { testWidgets( 'does NOT fire when opened at the bottom with an unseen pre-existing ' - 'unread boundary (FLU-640)', + 'unread boundary', (tester) async { final other = User(id: 'otherid'); final messages = generateConversation(20, users: [other]).reversed.toList(); @@ -510,6 +538,43 @@ void main() { }, ); + testWidgets( + 'retires once the user scrolls back to the unread boundary', + (tester) async { + final other = User(id: 'otherid'); + final messages = generateConversation(20, users: [other]).reversed.toList(); + + await pumpMessageList( + tester, + messages: messages, + isUpToDate: true, + unreadCount: 5, + markReadWhenAtTheBottom: false, + currentUserRead: Read( + user: ownUser, + lastRead: DateTime.now(), + unreadMessages: 5, + lastReadMessageId: messages[10].id, + ), + ); + + expect(find.byType(UnreadIndicatorButton), findsOneWidget); + + // Scrolling back until the boundary is on screen is what the pill + // exists to prompt, so reaching it retires the pill for good. + await tester.drag(find.byType(StreamMessageListView), const Offset(0, 1500)); + await tester.pumpAndSettle(); + + expect(find.byType(UnreadIndicatorButton), findsNothing); + + // Sticky: returning to the bottom does not bring it back. + await tester.drag(find.byType(StreamMessageListView), const Offset(0, -3000)); + await tester.pumpAndSettle(); + + expect(find.byType(UnreadIndicatorButton), findsNothing); + }, + ); + testWidgets( 'absent when the channel opened with nothing pre-existing unread', (tester) async { @@ -614,7 +679,7 @@ void main() { // moved-back boundary. Its anchor is the message the user was looking at, // so it starts out on screen — which used to retire the pill on the very // next layout tick. - Future> pumpMarkedUnread( + Future<({List messages, Read markedRead})> pumpMarkedUnread( WidgetTester tester, { required User other, }) async { @@ -646,7 +711,7 @@ void main() { await tester.pumpAndSettle(); }); - return messages; + return (messages: messages, markedRead: markedRead); } testWidgets( @@ -670,7 +735,7 @@ void main() { 'stays dismissed when further messages arrive after being dismissed', (tester) async { final other = User(id: 'otherid'); - final messages = await pumpMarkedUnread(tester, other: other); + final (:messages, :markedRead) = await pumpMarkedUnread(tester, other: other); expect(find.byType(UnreadIndicatorButton), findsOneWidget); @@ -693,12 +758,12 @@ void main() { ); final updated = [...messages, arrival]; when(() => channelClientState.messages).thenReturn(updated); - final laterRead = Read( - user: ownUser, - lastRead: DateTime.now(), - unreadMessages: 4, - lastReadMessageId: messages[messages.length - 2].id, - ); + // Only the count moves. An arrival never shifts the read boundary + // itself — `ChannelClientState.unreadCount` copies the existing + // `Read` with a new `unreadMessages` and nothing else — and the + // distinction matters, since a boundary that *did* move is how a + // second mark-unread is recognised. + final laterRead = markedRead.copyWith(unreadMessages: 4); when(() => channelClientState.currentUserRead).thenReturn(laterRead); await tester.runAsync(() async { @@ -723,6 +788,101 @@ void main() { expect(find.byType(UnreadIndicatorButton), findsNothing); }, ); + + testWidgets( + 'a second mark-unread further back re-anchors the pill', + (tester) async { + final other = User(id: 'otherid'); + final (:messages, :markedRead) = await pumpMarkedUnread(tester, other: other); + + UnreadIndicatorButton pill() => tester.widget( + find.byType(UnreadIndicatorButton), + ); + + expect(pill().unreadCount, 3); + + // Marking an older message unread while the first mark-unread is + // still active. `isMarkedAsUnread` never returned to false in + // between — only a mark-read clears it — so the flag on its own says + // nothing has happened. The read boundary moving back is the signal. + final secondMarkedRead = Read( + user: ownUser, + lastRead: markedRead.lastRead.subtract(const Duration(minutes: 5)), + unreadMessages: 6, + lastReadMessageId: messages[messages.length - 5].id, + ); + when(() => channelClientState.unreadCount).thenReturn(6); + when(() => channelClientState.currentUserRead).thenReturn(secondMarkedRead); + + await tester.runAsync(() async { + unreadCountController.add(6); + currentUserReadController.add(secondMarkedRead); + await tester.pumpAndSettle(); + }); + + expect(pill().unreadCount, 6); + }, + ); + }); + + group('mounting with a mark-unread already active', () { + testWidgets( + 'does not mark read until the viewport actually moves', + (tester) async { + // Channels tracking unread locally are exempt from the "boundary + // seen" gate, so the mark-unread viewport guard is all that stands + // between reopening the channel and silently undoing the + // mark-unread. The guard used to snapshot the viewport before the + // first layout, so the first laid-out frame already looked like the + // user had moved. + when(() => client.isLocalUnreadCountEnabled).thenReturn(true); + when(() => channelClientState.isMarkedAsUnread).thenReturn(true); + + final other = User(id: 'otherid'); + final messages = generateConversation(40, users: [other]).reversed.toList(); + final markedRead = Read( + user: ownUser, + lastRead: DateTime.now(), + unreadMessages: 3, + lastReadMessageId: messages[messages.length - 2].id, + ); + + // A seeded subject, like production's: subscribing replays the + // current value straight away, so the listener runs before the first + // frame reports any item positions. A plain broadcast controller + // can't reproduce that ordering, and this bug lives in it. + final seededReads = BehaviorSubject.seeded(markedRead); + addTearDown(seededReads.close); + when(() => channelClientState.currentUserReadStream).thenAnswer((_) => seededReads.stream); + + // Mounted before the messages land, as on a cold open: the read + // replay above therefore arrives while nothing is laid out yet and + // there is no viewport to snapshot. + await pumpMessageList( + tester, + messages: const [], + isUpToDate: true, + unreadCount: 3, + currentUserRead: markedRead, + ); + + when(() => channelClientState.messages).thenReturn(messages); + await tester.runAsync(() async { + messagesController.add(messages); + await tester.pumpAndSettle(); + }); + + verifyNever(() => channel.markRead(messageId: any(named: 'messageId'))); + + // Scrolling away and back is what earns the mark-read. + await tester.drag(find.byType(StreamMessageListView), const Offset(0, 300)); + await tester.pumpAndSettle(); + await tester.drag(find.byType(StreamMessageListView), const Offset(0, -1000)); + await tester.pumpAndSettle(); + + verify(() => channel.markRead(messageId: any(named: 'messageId'))).called(1); + }, + ); }); group('unread pill on a never-read channel', () { diff --git a/packages/stream_chat_flutter/test/src/message_list_view/unread_divider_test.dart b/packages/stream_chat_flutter/test/src/message_list_view/unread_divider_test.dart index 212d45253d..b540dd42b2 100644 --- a/packages/stream_chat_flutter/test/src/message_list_view/unread_divider_test.dart +++ b/packages/stream_chat_flutter/test/src/message_list_view/unread_divider_test.dart @@ -1,5 +1,5 @@ // Tests for the unread-messages divider, the jump-to-unread pill, and the -// scroll-to-bottom badge (FLU-649 / FLU-650). +// scroll-to-bottom badge. // // - The unread divider ("{n} unread messages"): anchored to the // pre-existing unread boundary captured when the channel opens. The @@ -195,6 +195,25 @@ void main() { ); expect(find.textContaining('unread message'), findsNothing); + + // Messages arriving while the channel is open must not introduce a + // separator of their own: there is exactly one divider, anchored at + // the boundary the channel opened with — and here there wasn't one. + await tester.drag(find.byType(StreamMessageListView), const Offset(0, 400)); + await tester.pumpAndSettle(); + + await deliverMessageNew( + tester, + newMessage: Message( + id: 'arrived-while-open', + text: 'Arrived while open', + user: other, + createdAt: DateTime.now(), + ), + existing: messages, + ); + + expect(find.textContaining('unread message'), findsNothing); }, ); @@ -296,6 +315,72 @@ void main() { return tester.widget(finder).props.label; } + group('openAtFirstUnread', () { + // Deterministic texts (rather than the faker-generated ones elsewhere in + // this file) so "which message is on screen" is a stable assertion. + List buildMessages(User author) => [ + for (var i = 0; i < 40; i++) + Message( + id: 'm$i', + text: 'message-$i', + user: author, + createdAt: DateTime.utc(2026).add(Duration(minutes: i)), + ), + ]; + + testWidgets('opens positioned at the first unread message by default', (tester) async { + final other = User(id: 'otherid'); + final messages = buildMessages(other); + + // Opening at the boundary re-queries the channel around it; the + // mocked state keeps returning the same window. + when( + () => channel.query( + preferOffline: any(named: 'preferOffline'), + messagesPagination: any(named: 'messagesPagination'), + ), + ).thenAnswer((_) async => const ChannelState()); + + await pumpMessageList( + tester, + messages: messages, + unreadCount: 34, + openAtFirstUnread: true, + currentUserRead: Read( + user: ownUser, + lastRead: DateTime.utc(2026, 1, 1, 0, 5), + unreadMessages: 34, + lastReadMessageId: 'm5', + ), + ); + + // The first unread message is on screen; the newest one is not. + expect(find.text('message-6'), findsOneWidget); + expect(find.text('message-39'), findsNothing); + }); + + testWidgets('opens at the latest message when set to false', (tester) async { + final other = User(id: 'otherid'); + final messages = buildMessages(other); + + await pumpMessageList( + tester, + messages: messages, + unreadCount: 34, + openAtFirstUnread: false, + currentUserRead: Read( + user: ownUser, + lastRead: DateTime.utc(2026, 1, 1, 0, 5), + unreadMessages: 34, + lastReadMessageId: 'm5', + ), + ); + + expect(find.text('message-39'), findsOneWidget); + expect(find.text('message-6'), findsNothing); + }); + }); + group('unread counting filters', () { // Messages the channel's own unread count ignores must not inflate the // badge or the divider either. Each case scrolls away from the bottom @@ -397,6 +482,58 @@ void main() { ); }); + testWidgets('messages restricted to other users do not count', (tester) async { + await expectNotCounted( + tester, + build: (other) => Message( + id: 'restricted-message', + text: 'Not for you', + user: other, + restrictedVisibility: const ['someoneelse'], + createdAt: DateTime.now(), + ), + ); + }); + + testWidgets('nothing counts while the user has read receipts disabled', (tester) async { + // No control arrival here, deliberately: this filter is user-level, so + // with it on nothing counts at all. The control is every other test in + // this group — they use the same delivery path with receipts enabled + // (the default) and do bump the badge. + final other = User(id: 'otherid'); + final messages = generateConversation(40, users: [other]).reversed.toList(); + + when(() => clientState.currentUser).thenReturn( + OwnUser( + id: 'ownid', + privacySettings: const PrivacySettings(readReceipts: ReadReceipts(enabled: false)), + ), + ); + + await pumpMessageList( + tester, + messages: messages, + unreadCount: 0, + currentUserRead: Read(user: ownUser, lastRead: DateTime.now(), unreadMessages: 0), + ); + + await tester.drag(find.byType(StreamMessageListView), const Offset(0, 400)); + await tester.pumpAndSettle(); + + await deliverMessageNew( + tester, + newMessage: Message( + id: 'ordinary-arrival', + text: 'Ordinary arrival', + user: other, + createdAt: DateTime.now(), + ), + existing: messages, + ); + + expect(badgeLabel(tester), isNull); + }); + testWidgets('messages from a muted user do not count', (tester) async { final other = User(id: 'otherid'); await expectNotCounted( @@ -543,6 +680,22 @@ void main() { await deliverMessageNew(tester, newMessage: ownMessage, existing: messages); expect(badgeLabel(tester), isNull); + + // Control: the same delivery path with someone else's message does + // bump the badge, so the assertion above isn't just measuring an + // inert pipeline. + await deliverMessageNew( + tester, + newMessage: Message( + id: 'control-from-other', + text: 'Ordinary arrival', + user: other, + createdAt: DateTime.now(), + ), + existing: [...messages, ownMessage], + ); + + expect(badgeLabel(tester), '1'); }, ); diff --git a/packages/stream_chat_flutter/test/src/message_list_view/unread_indicator_button_test.dart b/packages/stream_chat_flutter/test/src/message_list_view/unread_indicator_button_test.dart index b413c960f9..dd56cb64a5 100644 --- a/packages/stream_chat_flutter/test/src/message_list_view/unread_indicator_button_test.dart +++ b/packages/stream_chat_flutter/test/src/message_list_view/unread_indicator_button_test.dart @@ -99,12 +99,15 @@ void main() { }, ); - expect(find.byType(StreamJumpToUnreadButton), findsOneWidget); - - final button = tester.widget(find.byType(UnreadIndicatorButton)); - await button.onJumpTap( - channelClientState.currentUserRead!.lastReadMessageId, - ); + final pill = find.byType(StreamJumpToUnreadButton); + expect(pill, findsOneWidget); + + // Tapping the widget's own jump area, rather than calling `onJumpTap` + // directly: what has to keep working is the wiring from the leading + // section to the callback, including the argument the widget passes. + final label = tester.widget(pill).props.label; + await tester.tap(find.text(label)); + await tester.pumpAndSettle(); expect(called, isTrue); expect(received, 'boundary-id'); diff --git a/packages/stream_chat_flutter_core/test/stream_channel_test.dart b/packages/stream_chat_flutter_core/test/stream_channel_test.dart index 15c29bb8a2..cb5d4b3686 100644 --- a/packages/stream_chat_flutter_core/test/stream_channel_test.dart +++ b/packages/stream_chat_flutter_core/test/stream_channel_test.dart @@ -11,14 +11,16 @@ import 'mocks.dart'; Future _pumpStreamChannel( WidgetTester tester, - Channel channel, -) async { + Channel channel, { + bool openAtFirstUnread = true, +}) async { StreamChannelState? channelState; await tester.pumpWidget( MaterialApp( home: Scaffold( body: StreamChannel( channel: channel, + openAtFirstUnread: openAtFirstUnread, child: Builder( builder: (context) { channelState = StreamChannel.of(context); @@ -1092,6 +1094,71 @@ void main() { }, ); + testWidgets( + 'does not position at the boundary when openAtFirstUnread is false', + (tester) async { + final read = Read( + user: User(id: 'testUserId'), + lastRead: DateTime.now(), + unreadMessages: 100, + lastReadMessageId: 'last-read-msg', + ); + when(() => mockChannel.state.unreadCount).thenReturn(100); + when(() => mockChannel.state.currentUserRead).thenReturn(read); + + await _pumpStreamChannel(tester, mockChannel, openAtFirstUnread: false); + + // `isUpToDate` is true (setUp), so opting out of the unread anchor + // leaves nothing to query at all — the loaded window stays put at + // the latest page. + verifyNever( + () => mockChannel.query( + preferOffline: any(named: 'preferOffline'), + messagesPagination: any(named: 'messagesPagination'), + ), + ); + }, + ); + + testWidgets( + 'openAtFirstUnread: false still honours an explicit initialMessageId', + (tester) async { + final read = Read( + user: User(id: 'testUserId'), + lastRead: DateTime.now(), + unreadMessages: 100, + lastReadMessageId: 'last-read-msg', + ); + when(() => mockChannel.state.unreadCount).thenReturn(100); + when(() => mockChannel.state.currentUserRead).thenReturn(read); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StreamChannel( + channel: mockChannel, + openAtFirstUnread: false, + initialMessageId: 'jump-to-me', + child: const Text('Channel Content'), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final captured = + verify( + () => mockChannel.query( + preferOffline: any(named: 'preferOffline'), + messagesPagination: captureAny(named: 'messagesPagination'), + ), + ).captured.single + as PaginationParams; + + expect(captured.idAround, equals('jump-to-me')); + }, + ); + testWidgets( 'queries createdAtAround=lastRead when lastReadMessageId is null and ' 'lastRead is a real timestamp', diff --git a/packages/stream_chat_localizations/CHANGELOG.md b/packages/stream_chat_localizations/CHANGELOG.md index 19d1e8581e..be9bc2e2c3 100644 --- a/packages/stream_chat_localizations/CHANGELOG.md +++ b/packages/stream_chat_localizations/CHANGELOG.md @@ -3,10 +3,7 @@ ✅ Added - Added connection-error translations (`connectionErrorTitle`/`Description`, `slowConnectionErrorTitle`/`Description`, `genericErrorTitle`/`Description`) for all supported locales. -- Added `unreadMessagesSeparatorLabel` for all supported locales, showing a count (e.g. "5 unread messages"). - `GlobalStreamChatLocalizations` provides a default implementation that falls back to the deprecated - count-less `unreadMessagesSeparatorText`, so localization subclasses written before this method existed - keep compiling and keep showing any custom text they already override. +- Added `unreadMessagesSeparatorLabel` for all supported locales, showing a count (e.g. "5 unread messages"). `GlobalStreamChatLocalizations` falls back to the deprecated count-less `unreadMessagesSeparatorText`, so a subclass that extends it keeps showing any custom text it already overrides. A class that `implements StreamChatLocalizations` directly has to add the member itself. ## 10.2.0 diff --git a/packages/stream_chat_localizations/example/lib/add_new_lang.dart b/packages/stream_chat_localizations/example/lib/add_new_lang.dart index a292a97efa..bf0edb489b 100644 --- a/packages/stream_chat_localizations/example/lib/add_new_lang.dart +++ b/packages/stream_chat_localizations/example/lib/add_new_lang.dart @@ -508,7 +508,6 @@ class NnStreamChatLocalizations extends GlobalStreamChatLocalizations { String get viewLibrary => 'View library'; @override - // ignore: deprecated_member_use String unreadMessagesSeparatorText() => 'New messages'; @override diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations.dart index 45e480eda0..2701211722 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations.dart @@ -124,10 +124,15 @@ abstract class GlobalStreamChatLocalizations implements StreamChatLocalizations /// The label for the unread messages separator, e.g. "5 unread messages". /// - /// Defaults to the count-less `unreadMessagesSeparatorText` so subclasses - /// written before this method existed keep compiling, and any custom text - /// they already override keeps being shown. The bundled locales override - /// this to include the count. + /// Falls back to the count-less `unreadMessagesSeparatorText`, so a + /// subclass written before this method existed keeps rendering the custom + /// text it already overrides instead of reverting to the built-in copy. + /// The bundled locales override this to include the count. + /// + /// Note that the fallback only helps classes that extend (or mix in) + /// [GlobalStreamChatLocalizations]: Dart does not inherit method bodies + /// through `implements`, so a class implementing [StreamChatLocalizations] + /// directly has to add this member. See the CHANGELOG for the migration. @override String unreadMessagesSeparatorLabel({required int count}) { // ignore: deprecated_member_use diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart index 96bcbcc620..bbddffad78 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart @@ -493,13 +493,16 @@ class StreamChatLocalizationsCa extends GlobalStreamChatLocalizations { String get linkDisabledError => 'Els enllaços estan deshabilitats'; @override - // ignore: deprecated_member_use String unreadMessagesSeparatorText() => 'Missatges nous'; @override String unreadMessagesSeparatorLabel({required int count}) { - if (count == 1) return '1 missatge no llegit'; - return '$count missatges no llegits'; + return Intl.plural( + count, + one: '$count missatge no llegit', + other: '$count missatges no llegits', + locale: localeName, + ); } @override diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart index 47473089d7..3ac8388d8c 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart @@ -490,13 +490,16 @@ class StreamChatLocalizationsDe extends GlobalStreamChatLocalizations { String get viewLibrary => 'Bibliothek öffnen'; @override - // ignore: deprecated_member_use String unreadMessagesSeparatorText() => 'Neue Nachrichten'; @override String unreadMessagesSeparatorLabel({required int count}) { - if (count == 1) return '1 ungelesene Nachricht'; - return '$count ungelesene Nachrichten'; + return Intl.plural( + count, + one: '$count ungelesene Nachricht', + other: '$count ungelesene Nachrichten', + locale: localeName, + ); } @override diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart index 18787fa8d2..41d24f7dfd 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart @@ -492,13 +492,16 @@ class StreamChatLocalizationsEn extends GlobalStreamChatLocalizations { String get viewLibrary => 'View library'; @override - // ignore: deprecated_member_use String unreadMessagesSeparatorText() => 'New messages'; @override String unreadMessagesSeparatorLabel({required int count}) { - if (count == 1) return '1 unread message'; - return '$count unread messages'; + return Intl.plural( + count, + one: '$count unread message', + other: '$count unread messages', + locale: localeName, + ); } @override diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart index 9948b0565f..ae7c9f7d2f 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart @@ -496,13 +496,16 @@ No es posible añadir más de $limit archivos adjuntos String get linkDisabledError => 'Los enlaces están deshabilitados'; @override - // ignore: deprecated_member_use String unreadMessagesSeparatorText() => 'Nuevos mensajes'; @override String unreadMessagesSeparatorLabel({required int count}) { - if (count == 1) return '1 mensaje no leído'; - return '$count mensajes no leídos'; + return Intl.plural( + count, + one: '$count mensaje sin leer', + other: '$count mensajes sin leer', + locale: localeName, + ); } @override diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart index 5fcfb11c5d..82144ce49e 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart @@ -496,13 +496,16 @@ Limite de pièces jointes dépassée : il n'est pas possible d'ajouter plus de $ String get linkDisabledError => 'Les liens sont désactivés'; @override - // ignore: deprecated_member_use String unreadMessagesSeparatorText() => 'Nouveaux messages'; @override String unreadMessagesSeparatorLabel({required int count}) { - if (count == 1) return '1 message non lu'; - return '$count messages non lus'; + return Intl.plural( + count, + one: '$count message non lu', + other: '$count messages non lus', + locale: localeName, + ); } @override diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart index cdfc4829c3..e17cf55a8d 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart @@ -494,13 +494,16 @@ class StreamChatLocalizationsHi extends GlobalStreamChatLocalizations { String get linkDisabledError => 'लिंक भेजना प्रतिबंधित'; @override - // ignore: deprecated_member_use String unreadMessagesSeparatorText() => 'नए संदेश।'; @override String unreadMessagesSeparatorLabel({required int count}) { - if (count == 1) return '1 अपठित संदेश'; - return '$count अपठित संदेश'; + return Intl.plural( + count, + one: '$count अपठित संदेश', + other: '$count अपठित संदेश', + locale: localeName, + ); } @override diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart index 2dc812419d..23df802c2c 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart @@ -499,13 +499,16 @@ Attenzione: il limite massimo di $limit file è stato superato. String get linkDisabledError => 'I links sono disattivati'; @override - // ignore: deprecated_member_use String unreadMessagesSeparatorText() => 'Nuovi messaggi'; @override String unreadMessagesSeparatorLabel({required int count}) { - if (count == 1) return '1 messaggio non letto'; - return '$count messaggi non letti'; + return Intl.plural( + count, + one: '$count messaggio non letto', + other: '$count messaggi non letti', + locale: localeName, + ); } @override diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart index 95198e0169..c502ce6748 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart @@ -482,12 +482,16 @@ class StreamChatLocalizationsJa extends GlobalStreamChatLocalizations { String get linkDisabledError => 'リンクが無効になっています'; @override - // ignore: deprecated_member_use String unreadMessagesSeparatorText() => '新しいメッセージ。'; @override String unreadMessagesSeparatorLabel({required int count}) { - return '未読メッセージ $count 件'; + return Intl.plural( + count, + one: '$count件の未読メッセージ', + other: '$count件の未読メッセージ', + locale: localeName, + ); } @override diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart index c895b5b57e..b3141c3277 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart @@ -483,12 +483,16 @@ class StreamChatLocalizationsKo extends GlobalStreamChatLocalizations { String get linkDisabledError => '링크가 비활성화되었습니다.'; @override - // ignore: deprecated_member_use String unreadMessagesSeparatorText() => '새 메시지.'; @override String unreadMessagesSeparatorLabel({required int count}) { - return '읽지 않은 메시지 $count개'; + return Intl.plural( + count, + one: '읽지 않은 메시지 $count개', + other: '읽지 않은 메시지 $count개', + locale: localeName, + ); } @override diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart index c2f78dcc56..879d98481e 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart @@ -434,13 +434,16 @@ class StreamChatLocalizationsNo extends GlobalStreamChatLocalizations { String get viewLibrary => 'Se bibliotek'; @override - // ignore: deprecated_member_use String unreadMessagesSeparatorText() => 'Nye meldinger.'; @override String unreadMessagesSeparatorLabel({required int count}) { - if (count == 1) return '1 ulest melding'; - return '$count uleste meldinger'; + return Intl.plural( + count, + one: '$count ulest melding', + other: '$count uleste meldinger', + locale: localeName, + ); } @override diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart index d61d0957fc..02dcda4b73 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart @@ -495,13 +495,16 @@ Não é possível adicionar mais de $limit arquivos de uma vez String get viewLibrary => 'Ver biblioteca'; @override - // ignore: deprecated_member_use String unreadMessagesSeparatorText() => 'Novas mensagens'; @override String unreadMessagesSeparatorLabel({required int count}) { - if (count == 1) return '1 mensagem não lida'; - return '$count mensagens não lidas'; + return Intl.plural( + count, + one: '$count mensagem não lida', + other: '$count mensagens não lidas', + locale: localeName, + ); } @override diff --git a/packages/stream_chat_localizations/test/translations_test.dart b/packages/stream_chat_localizations/test/translations_test.dart index da63015182..a2dd1e9e2b 100644 --- a/packages/stream_chat_localizations/test/translations_test.dart +++ b/packages/stream_chat_localizations/test/translations_test.dart @@ -232,8 +232,18 @@ void main() { expect(localizations.enableFileAccessMessage, isNotNull); expect(localizations.allowFileAccessMessage, isNotNull); expect(localizations.unreadCountIndicatorLabel(unreadCount: 2), isNotNull); - expect(localizations.unreadMessagesSeparatorLabel(count: 1), isNotNull); - expect(localizations.unreadMessagesSeparatorLabel(count: 2), isNotNull); + // Deliberately not `isNotNull` — the return type is non-nullable, so + // that can never fail. Asserting the count is actually rendered is what + // catches a locale that forgot to override this and silently fell back + // to the count-less deprecated string. + expect(localizations.unreadMessagesSeparatorLabel(count: 0), contains('0')); + expect(localizations.unreadMessagesSeparatorLabel(count: 1), contains('1')); + expect(localizations.unreadMessagesSeparatorLabel(count: 2), contains('2')); + expect( + localizations.unreadMessagesSeparatorLabel(count: 2), + // ignore: deprecated_member_use + isNot(localizations.unreadMessagesSeparatorText()), + ); expect(localizations.markUnreadError, isNotNull); expect(localizations.markAsUnreadLabel, isNotNull); // Create poll From 07a0fbf89e757c6cb56115d93f04a2bb24a356d1 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Fri, 21 Aug 2026 10:05:00 +0200 Subject: [PATCH 10/14] refactor unread into controller (#2908) * refactor unread into controller * make controller internal and improve types * minor PR improvements --- packages/stream_chat_flutter/CHANGELOG.md | 2 +- .../message_list_unread_controller.dart | 744 ++++++++++++++++++ .../message_list_view/message_list_view.dart | 641 +-------------- .../message_list_unread_controller_test.dart | 650 +++++++++++++++ .../unread_divider_test.dart | 4 +- 5 files changed, 1437 insertions(+), 604 deletions(-) create mode 100644 packages/stream_chat_flutter/lib/src/message_list_view/message_list_unread_controller.dart create mode 100644 packages/stream_chat_flutter/test/src/message_list_view/message_list_unread_controller_test.dart diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index 5e45074a7a..8665c6a3ba 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -18,7 +18,7 @@ - `Translations.unreadMessagesSeparatorLabel` is a new interface member. Classes that `extends Translations` (or `GlobalStreamChatLocalizations`) inherit the fallback and need no change, but a class that `implements` either interface directly must add this member — Dart does not inherit method bodies through `implements`. Forward it to your existing `unreadMessagesSeparatorText()` to keep the previous copy. - Changed the "↑ N unread" jump-to-unread pill to a count fixed when the channel opens, shown as soon as that count is known and dismissed permanently for the session once tapped, dismissed, or scrolled past. - Changed the scroll-to-bottom badge to count only messages that arrive out of view during the current session, rather than being seeded from the channel's unread count. It always resets to 0 once the user reaches the bottom. -- Changed the "unread messages" divider to show a count, starting at the channel's open-time unread total and counting up as further messages arrive during the session — mirroring WhatsApp — instead of a fixed, count-less label. +- Changed the "unread messages" divider to show a count, starting at the channel's open-time unread total and counting up as further messages arrive during the session, instead of a fixed, count-less label. - Tightened `StreamMessageListView`'s automatic mark-read gating to also require that the pre-existing unread boundary has been seen or scrolled past, and that there's no pending manual mark-unread. Channels with no boundary to reach — opened fully read, never opened at all, or tracking unread locally — are unaffected. ⚠️ Deprecated diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_unread_controller.dart b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_unread_controller.dart new file mode 100644 index 0000000000..8aa6bc161d --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_unread_controller.dart @@ -0,0 +1,744 @@ +import 'dart:math'; + +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:stream_chat_flutter/scrollable_positioned_list/scrollable_positioned_list.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +// The current user's read boundary: where in the channel they had read up to. +// A mark-unread moves it backward, which is how a fresh one is told apart +// from the read-stream emissions that keep arriving during a session. +// +// A record rather than a class so equality stays structural — the whole point +// is comparing a newly observed boundary against the previous one. +typedef _ReadBoundary = ({DateTime? lastRead, String? lastReadMessageId}); + +// Every input the mark-read gate reads, captured as the key of one attempt. +// Comparing a new key against the last one is what stops a mark-read that +// keeps failing from being retried on every scroll frame, while still letting +// a genuine change through. +// +// Also a record for its structural equality, which the comparison relies on. +typedef _MarkReadAttempt = ({ + String? newestMessageId, + int unreadCount, + bool isMarkedAsUnread, + bool viewportDiverged, +}); + +/// Owns the unread-message state machine behind [StreamMessageListView]: +/// the unread messages divider and its floating pill, the scroll-to-bottom +/// badge count, and the auto mark-read gate (including protection for an +/// active manual mark-unread). +/// +/// The list forwards raw signals in — channel attach, message arrivals, item +/// position ticks, read-stream emissions and pill taps — and renders from the +/// exposed [ValueListenable]s. This class never touches the widget tree, and +/// reaches the list's live state only through the accessors it is given. +@internal +class MessageListUnreadController { + /// Creates a controller wired to the message list's live state. + /// + /// Every dependency is a function rather than a value because the state + /// behind it changes over the list's lifetime: the channel is reassigned on + /// a channel change, `messages` on every stream emission, and the widget's + /// configuration on any rebuild. + MessageListUnreadController({ + required Channel? Function() channel, + required Message? Function(Read? currentUserRead) getFirstUnreadMessage, + required Message? Function() parentMessage, + required List Function() messages, + required Iterable Function() itemPositions, + required bool Function() markReadWhenAtTheBottom, + required Future Function(String messageId) scrollToMessage, + required Object? Function() attachToken, + }) : _channel = channel, + _getFirstUnreadMessage = getFirstUnreadMessage, + _parentMessage = parentMessage, + _messages = messages, + _itemPositions = itemPositions, + _markReadWhenAtTheBottom = markReadWhenAtTheBottom, + _scrollToMessage = scrollToMessage, + _attachToken = attachToken; + + final Channel? Function() _channel; + final Message? Function(Read? currentUserRead) _getFirstUnreadMessage; + final Message? Function() _parentMessage; + final List Function() _messages; + final Iterable Function() _itemPositions; + final bool Function() _markReadWhenAtTheBottom; + + // Scrolls the list to a message without highlighting it, reporting whether + // the scroll actually landed. + final Future Function(String messageId) _scrollToMessage; + + // Identity of the current channel attachment, compared across awaits so a + // result that arrives after the list re-attached elsewhere is dropped. + final Object? Function() _attachToken; + + bool get _isThreadConversation => _parentMessage() != null; + + bool _disposed = false; + + // --- Divider A: pre-existing unread, frozen at channel open --- + // + // [_unreadBaseline] is the current user's [Read] captured once when the + // channel is attached (or on the first `currentUserReadStream` emission if + // read state wasn't available yet). It never changes afterwards, so + // resolving the anchor against it — rather than against the live, + // ever-shrinking `unreadCount` — is what keeps the divider and pill on + // screen across an auto mark-read. + Read? _unreadBaseline; + bool _unreadBaselineCaptured = false; + + // Resolved anchor for divider A. The anchor (and `count`, the frozen + // baseline used by the pill) is frozen once non-null: recomputation is + // skipped as soon as `anchorId` is set. May take a few rebuilds to resolve + // if top pagination hasn't finished loading the boundary yet. + final _unreadDivider = ValueNotifier<({int count, String? anchorId})>((count: 0, anchorId: null)); + + // Grows by one for every message that arrives out of view while divider + // A is on screen, so the divider's displayed count keeps counting up + // during the session instead of staying frozen at the open-time count. + // Added on top of `_unreadDivider.value.count` for display only — the + // pill keeps using the frozen count. + final ValueNotifier _unreadDividerGrowth = ValueNotifier(0); + + // Sticky: becomes true once the user has seen (rendered) or scrolled past + // divider A's anchor. Drives the pill's permanent dismissal and (see + // [_maybeMarkMessagesAsRead]) gates auto mark-read. + final ValueNotifier _hasSeenFirstUnread = ValueNotifier(false); + + // Whether the list has reported item positions at least once. + // + // The pill waits on this. Its count is published synchronously while the + // channel is attached, but `_hasSeenFirstUnread` can only be decided from + // item positions, which arrive in a post-frame callback — so without this + // the pill paints for exactly one frame on every channel opened at its + // first unread message, then disappears. + final ValueNotifier _hasLaidOut = ValueNotifier(false); + + // Scroll-to-bottom badge count. Counts messages that arrive while the user + // is scrolled away from the bottom; resets to 0 once they reach the bottom. + final ValueNotifier _scrollToBottomBadge = ValueNotifier(0); + + // Sticky "bottom was reached" flag for the mark-read gate. Cleared after + // each successful mark-read so returning to the bottom is required again + // before the next one. + bool _hasSeenLastMessage = false; + + // While non-null, the viewport captured at the moment an active manual + // mark-unread (`channel.state.isMarkedAsUnread`) was first observed. + // `_maybeMarkMessagesAsRead` blocks until [_markUnreadViewportDiverged] + // is true — evidence the user did something (scrolled, reopened the + // channel, etc.) since marking the message unread, rather than the + // anchor merely being immediately "visible" again because it's usually + // the very message just marked and nothing has moved. + // + // This can't gate on `isMarkedAsUnread` directly and permanently: that + // flag only clears via a successful mark-read, which is the very thing + // it would be gating, so treating it as a persistent block would + // deadlock the channel unread forever the moment it's set — the exact + // bug this snapshot exists to avoid. + // + // Set eagerly in [handleCurrentUserReadChanged] right when a live + // transition is observed (captures the precise pre-scroll viewport), and + // in [handleItemPositionsChanged] on the first genuinely laid-out frame + // as a fallback for when the channel simply mounts with + // `isMarkedAsUnread` already true and no transition ever fires — that + // has to happen there and not lazily inside [_maybeMarkMessagesAsRead], + // since the first time that gate is evaluated might already be the + // user's first genuine arrival at the bottom, which would otherwise be + // burned on capturing the baseline instead of acting on it. Cleared once + // a mark-read actually goes through, or once `isMarkedAsUnread` itself + // clears (so a future mark-unread starts its own fresh snapshot). + // + // Holds visible item *indices* rather than full [ItemPosition]s: comparing + // full positions would latch divergence on a sub-pixel edge change from an + // unrelated relayout (async attachment sizing, keyboard inset, image + // load) even though the user never scrolled, undoing the manual + // mark-unread almost instantly. + List? _markUnreadViewportSnapshot; + + // Sticky once true: sighted the first time [handleItemPositionsChanged] + // (or, as a fallback, [_maybeMarkMessagesAsRead] itself) sees item + // positions that genuinely differ from [_markUnreadViewportSnapshot]. + // Deliberately tracked as "did this ever happen" rather than + // re-comparing the *current* positions against the snapshot on each + // check — a user who scrolls away and back settles at the exact same + // rest position, which would otherwise look unchanged and re-block a + // mark-read that should already have been earned by that round trip. + bool _markUnreadViewportDiverged = false; + + // State the last mark-read attempt was made against. Item positions tick + // on every scroll frame, so without this a mark-read that keeps failing + // would be retried for as long as the user keeps scrolling at the bottom + // (once a second, as bounded by the debounce). Every input the gate in + // [_maybeMarkMessagesAsRead] actually reads is part of the key, so a + // genuine change — a new message, a mark-unread, the viewport diverging + // after one — still gets its attempt. + _MarkReadAttempt? _lastMarkReadAttempt; + + // Previous value of `channel.state.isMarkedAsUnread`, so + // [handleCurrentUserReadChanged] can act on a new mark-unread rather than + // on every read-stream emission that happens while the flag stays set. + // Seeded from the channel on attach, since it can already be set there. + bool _wasMarkedAsUnread = false; + + // Read boundary observed alongside [_wasMarkedAsUnread]. A mark-unread + // moves the boundary backward, so a change here while the flag is already + // set is how a *second* mark-unread is told apart from the read-stream + // emissions that keep arriving during one. + _ReadBoundary? _lastReadBoundary; + + static _ReadBoundary? _readBoundaryOf(Read? read) { + if (read == null) return null; + return (lastRead: read.lastRead, lastReadMessageId: read.lastReadMessageId); + } + + // Whether divider A's current session came from an explicit mark-unread + // rather than from pre-existing unread at channel open. The anchor of a + // manual mark-unread is the message the user was looking at when they + // marked it, so it's already on screen — see + // [_maybeUpdateHasSeenFirstUnread] for why that changes what counts as + // having reached the boundary. + bool _unreadFromManualMarkUnread = false; + + /// The unread divider's frozen open-time count and, once resolved, the id + /// of the message it anchors to. + ValueListenable<({int count, String? anchorId})> get unreadDivider => _unreadDivider; + + /// Qualifying messages that have arrived since the divider's baseline was + /// frozen, added on top of [unreadDivider]'s count for display. + ValueListenable get unreadDividerGrowth => _unreadDividerGrowth; + + /// Whether the divider's anchor has been seen or scrolled past. + ValueListenable get hasSeenFirstUnread => _hasSeenFirstUnread; + + /// Whether the list has reported item positions at least once. + ValueListenable get hasLaidOut => _hasLaidOut; + + /// Messages that arrived while the user was scrolled away from the bottom. + ValueListenable get scrollToBottomBadge => _scrollToBottomBadge; + + /// Whether a baseline was captured but its anchor still needs resolving, + /// meaning [resolveDividerAnchor] is worth retrying once layout settles. + bool get needsAnchorResolution => _unreadBaseline != null && _unreadDivider.value.anchorId == null; + + // Debounced channel mark-read. + late final _debouncedMarkRead = debounce( + ([String? id]) => _channel()?.markRead(messageId: id), + const Duration(seconds: 1), + leading: true, + ); + + // Debounced thread mark-read. + late final _debouncedMarkThreadRead = debounce( + (String parentId) => _channel()?.markThreadRead(parentId), + const Duration(seconds: 1), + leading: true, + ); + + /// Resets every piece of unread state for a newly attached channel. + /// + /// Must be called after the new channel is reachable through the accessors + /// this controller was given, and before subscribing to that channel's read + /// stream — the seeding below exists precisely to survive that stream's + /// immediate replay. + void attach() { + _debouncedMarkRead.cancel(); + _debouncedMarkThreadRead.cancel(); + + final channelState = _channel()?.state; + + _unreadBaselineCaptured = false; + _unreadBaseline = null; + _unreadDivider.value = (count: 0, anchorId: null); + _unreadDividerGrowth.value = 0; + _scrollToBottomBadge.value = 0; + _hasSeenFirstUnread.value = false; + _hasSeenLastMessage = false; + _hasLaidOut.value = false; + _lastMarkReadAttempt = null; + _markUnreadViewportSnapshot = null; + _markUnreadViewportDiverged = false; + // Seeded from the channel's own state rather than hardcoded to false: + // a channel can mount with a manual mark-unread already active (mark + // unread, leave, come back — the flag lives on the cached + // `ChannelClientState`). `currentUserReadStream` is backed by a + // `BehaviorSubject`, so the subscription the list sets up replays the + // current value straight away; without this seed that replay would read + // as a brand-new mark-unread and restart a session that never ended. + _wasMarkedAsUnread = channelState?.isMarkedAsUnread ?? false; + _lastReadBoundary = _readBoundaryOf(channelState?.currentUserRead); + _unreadFromManualMarkUnread = _wasMarkedAsUnread; + _captureUnreadBaselineIfNeeded(); + } + + // Captures [_unreadBaseline] the first time the current user's read state + // becomes available, then attempts to resolve divider A's anchor against + // it. No-ops in a thread, where divider A doesn't apply. + void _captureUnreadBaselineIfNeeded() { + if (_unreadBaselineCaptured || _isThreadConversation) return; + + final currentUserRead = _channel()?.state?.currentUserRead; + if (currentUserRead == null) return; + + _unreadBaselineCaptured = true; + _unreadBaseline = currentUserRead.unreadMessages > 0 ? currentUserRead : null; + // Publish the frozen count right away, even though the anchor itself + // can't resolve until top pagination has loaded that far back — the + // pill only needs the count, not the anchor, so it shouldn't wait on + // pagination to appear (see [onPillJumpTapped] for how a tap before the + // anchor resolves still jumps there). + if (_unreadBaseline case final baseline?) { + _unreadDivider.value = (count: baseline.unreadMessages, anchorId: _unreadDivider.value.anchorId); + } + resolveDividerAnchor(); + } + + /// Resolves divider A's anchor against the frozen baseline. A no-op once + /// resolved, and while top pagination hasn't loaded the boundary yet. + void resolveDividerAnchor() { + if (_isThreadConversation || _unreadDivider.value.anchorId != null) return; + + final baseline = _unreadBaseline; + if (baseline == null) return; + + final anchor = _getFirstUnreadMessage(baseline); + if (anchor == null) return; + + _unreadDivider.value = (count: baseline.unreadMessages, anchorId: anchor.id); + } + + /// Reacts to a `currentUserReadStream` emission. An explicit mark-unread + /// moves the read boundary backward — treat it as a new session start for + /// divider A/the pill. + /// + /// The reset is deliberately gated on a *new* mark-unread — the flag + /// turning on, or the read boundary moving again while it's already on — + /// rather than on the flag merely being set: the read stream also emits + /// while it stays set (every new message, for one), and re-running the + /// reset then would clear `_hasSeenFirstUnread` again and flicker the pill + /// back in and straight out on each arrival. An arriving message bumps + /// `unreadMessages` but leaves the boundary untouched, so gating on the + /// boundary avoids that flicker while still catching a second mark-unread. + /// + /// Watching the boundary rather than only the transition matters because + /// `isMarkedAsUnread` is cleared solely by a mark-read (see + /// `ChannelClientState.markReadLocally`), and both the baseline capture and + /// [resolveDividerAnchor] freeze once resolved — so this reset is the only + /// thing that can move divider A once a mark-unread session is under way. + void handleCurrentUserReadChanged() { + if (_isThreadConversation) return; + + final channel = _channel(); + if (channel == null) return; + + final isMarkedAsUnread = channel.state?.isMarkedAsUnread ?? false; + final readBoundary = _readBoundaryOf(channel.state?.currentUserRead); + final boundaryMoved = readBoundary != _lastReadBoundary; + final justMarkedAsUnread = isMarkedAsUnread && (!_wasMarkedAsUnread || boundaryMoved); + _wasMarkedAsUnread = isMarkedAsUnread; + _lastReadBoundary = readBoundary; + + if (justMarkedAsUnread) { + _unreadBaselineCaptured = false; + _unreadBaseline = null; + _unreadDivider.value = (count: 0, anchorId: null); + _unreadDividerGrowth.value = 0; + _hasSeenFirstUnread.value = false; + _unreadFromManualMarkUnread = true; + // Each mark-unread starts its own session, so the snapshot is taken + // fresh here rather than kept from a previous one — but only from a + // viewport that has actually been laid out. `itemPositions` is still + // empty before the first frame, and capturing that would make the + // very first laid-out frame look like divergence, defeating guard 4 + // and leaving the fallback in [handleItemPositionsChanged] + // unreachable. Left null instead, for that fallback to fill in. + final visibleIndices = _itemPositions().map((it) => it.index).toList(); + _markUnreadViewportSnapshot = visibleIndices.isEmpty ? null : visibleIndices; + _markUnreadViewportDiverged = false; + } else if (!isMarkedAsUnread) { + _unreadFromManualMarkUnread = false; + _markUnreadViewportSnapshot = null; + _markUnreadViewportDiverged = false; + } + + _captureUnreadBaselineIfNeeded(); + } + + /// Counts a freshly arrived [message] towards the divider's growing count + /// and, when the user isn't at the bottom, the scroll-to-bottom badge. + /// + /// Qualifying arrivals are filtered the same way the channel's own unread + /// count filters them, so silent, shadowed, ephemeral, thread-only, + /// restricted, muted-sender and own messages don't inflate either counter. + /// The badge and divider also only apply to the channel's message stream, + /// never to thread replies. + void handleMessageArrived( + Message message, { + required OwnUser? currentUser, + required bool isAtBottom, + }) { + final countsAsUnread = _countsTowardsUnreadIndicators(message, currentUser); + if (_isThreadConversation || !countsAsUnread) return; + + // The divider counts every qualifying arrival — including ones seen + // live at the bottom — so it keeps counting up for the whole session. + // The badge is narrower: it only exists to flag what was missed while + // scrolled away, so it skips arrivals that were already in view and + // resets once the bottom is reached (see [handleItemPositionsChanged]). + _unreadDividerGrowth.value += 1; + if (!isAtBottom) _scrollToBottomBadge.value += 1; + } + + /// Processes an item-positions tick, with [isAtBottom] reporting whether + /// the newest message is fully visible. + void handleItemPositionsChanged( + Iterable itemPositions, { + required bool isAtBottom, + }) { + // Guarded here as well as at the call site: an empty viewport is not a + // laid-out one, and letting it through would both flip [hasLaidOut] on a + // frame that renders nothing and let + // [_checkMarkUnreadViewportDivergence] snapshot an empty index set — + // which the very next non-empty frame would read as divergence, undoing + // an active manual mark-unread. See [handleCurrentUserReadChanged], + // which avoids capturing that same empty viewport for this reason. + if (itemPositions.isEmpty) return; + + _hasLaidOut.value = true; + + // Snapshot the viewport (or check it against an existing snapshot for + // divergence) the first time it's genuinely laid out while marked as + // unread, in case the channel simply mounted in that state rather than + // [handleCurrentUserReadChanged] observing a live transition to hook + // the snapshot on. Doing this here — on every non-empty layout, before + // checking anything else below — rather than lazily inside + // [_maybeMarkMessagesAsRead], matters: that gate is only ever evaluated + // when a mark-read could fire, which for a channel the user opens and + // immediately scrolls all the way through might be the very first time + // they reach the bottom. Capturing the baseline there would burn that + // first genuine read on the snapshot itself instead of acting on it. + if (_channel()?.state?.isMarkedAsUnread ?? false) { + _checkMarkUnreadViewportDivergence(itemPositions); + } + + final justSeenFirstUnread = _maybeUpdateHasSeenFirstUnread(itemPositions); + + if (isAtBottom) { + _hasSeenLastMessage = true; + _scrollToBottomBadge.value = 0; + } + + // Attempt a mark-read whenever either half of the gate could have just + // become satisfied; [_maybeMarkMessagesAsRead] does the actual deciding. + if ((isAtBottom || justSeenFirstUnread) && _markReadWhenAtTheBottom()) { + _maybeMarkMessagesAsRead(isAtBottom: isAtBottom).ignore(); + } + } + + /// Handles a tap on the pill's jump affordance. + Future onPillJumpTapped() async { + // The anchor may not have resolved yet if top pagination hasn't loaded + // that far back — the pill is visible already (see its gating in the + // list), so fall back to the frozen baseline's own last-read boundary, + // known immediately from the server `Read`, rather than doing nothing. + final anchorId = _unreadDivider.value.anchorId ?? _unreadBaseline?.lastReadMessageId; + + // A channel the user has never opened reports unread messages but has + // no read boundary at all: the anchor can't resolve until top + // pagination ends, and there's no `lastReadMessageId` to fall back on + // either. Everything loaded is unread, so head for the oldest message + // currently loaded — as far back as the boundary can be, and it pulls + // the next page in on arrival — rather than leaving the tap inert. + // + // `_hasSeenFirstUnread` is deliberately not latched here: the real + // boundary is further back than where this lands, so the pill stays up + // until it's genuinely reached. + if (anchorId == null) { + final oldestLoaded = _messages().lastOrNull; + if (oldestLoaded == null) return; + await _scrollToMessage(oldestLoaded.id); + return; + } + + // Delegates to the list's scroll-to-message, which falls back to + // [StreamChannelState.loadChannelAtMessage] when the anchor isn't in the + // currently loaded window — after which the real anchor resolves + // naturally via the list's retry of [resolveDividerAnchor], rendering + // divider A too. That can await pagination and a frame, and this + // controller survives a channel change — so remember which channel the + // tap was for and drop the result if it isn't the current one any more. + final tappedFor = _attachToken(); + final didJump = await _scrollToMessage(anchorId); + if (_disposed || _attachToken() != tappedFor) return; + // Only claim the boundary as seen once the jump actually landed — + // otherwise (message not found even after pagination, or the list not + // attached) the pill would vanish and the mark-read gate would open for + // a boundary the user never actually reached. + if (didJump) _hasSeenFirstUnread.value = true; + } + + /// Handles a tap on the pill's dismiss affordance. + Future onPillDismissTapped() async { + _hasSeenFirstUnread.value = true; + // Dismissing is a local decision; if the request behind it fails there + // is nothing to show the user, and letting it escape here would surface + // as an unhandled async error instead. + markAsRead().ignore(); + } + + /// Marks the channel — or, in a thread, the thread — as read immediately, + /// bypassing the debouncers. + Future markAsRead() async { + if (_parentMessage() case final parent?) { + // If we are in a thread, mark the thread as read immediately. + await _channel()?.markThreadRead(parent.id); + return; + } + + // Otherwise, mark the channel as read immediately. + await _channel()?.markRead(); + } + + Future _debouncedMarkMessagesAsRead() async { + if (_parentMessage() case final parent?) { + // If we are in a thread, mark the thread as read. + _debouncedMarkThreadRead.call([parent.id]); + } else { + // Otherwise, mark the channel as read. + _debouncedMarkRead.call(); + } + } + + // Whether a freshly-arrived [message] should bump the scroll-to-bottom + // badge and divider A's growing count. + // + // This is the message- and sender-level half of + // [MessageRules.canCountAsUnread], which is what keeps silent, shadowed, + // ephemeral, thread-only, restricted, muted-sender and own messages from + // inflating either counter. + // + // The channel-level half of that rule (`isMuted`, `canUseReadReceipts`, + // `usesLocalUnreadCount`) is deliberately left out. Those govern whether + // the server tracks an unread count for the channel at all, whereas these + // two counters are purely local "what arrived while you weren't looking" + // indicators that should keep working either way — and + // `usesLocalUnreadCount` is an extension getter reading `Channel`'s + // private client field, so it can't be resolved against a channel double + // at all. + // + // The user-level half (`isReadReceiptsEnabled`) is *not* left out: it + // isn't about how the channel is configured but about the user opting out + // of unread tracking entirely, and honouring it here is what keeps these + // indicators from counting up while the channel itself reports zero. + bool _countsTowardsUnreadIndicators(Message message, OwnUser? currentUser) { + if (currentUser == null) return false; + if (!currentUser.isReadReceiptsEnabled) return false; + + if (message.silent) return false; + if (message.shadowed) return false; + if (message.isEphemeral) return false; + + // Thread replies don't count towards the channel's unread state unless + // they were explicitly also sent to the channel. + if (message.parentId != null && message.showInChannel != true) return false; + + final sender = message.user; + if (sender == null) return false; + if (sender.id == currentUser.id) return false; + + if (message.isNotVisibleTo(currentUser.id)) return false; + + final isSenderMuted = currentUser.mutes.any((it) => it.target.id == sender.id); + if (isSenderMuted) return false; + + return true; + } + + // Captures [_markUnreadViewportSnapshot] the first time this is called, + // and otherwise checks [itemPositions] against it, latching + // [_markUnreadViewportDiverged] the first time they genuinely differ. + // Deliberately latching rather than re-comparing *current* positions + // against the snapshot on every check: a user who scrolls away and back + // settles at the exact same rest position, which would otherwise look + // unchanged and re-block a mark-read the round trip should already have + // earned. Safe to call on every position-changed tick — a no-op once + // already diverged. + // + // Compares the set of visible item *indices* rather than full + // [ItemPosition]s (which also carry leading/trailing edge offsets) — an + // unrelated relayout that nudges an edge by a fraction of a pixel isn't + // evidence the user did anything, and shouldn't count as divergence. + void _checkMarkUnreadViewportDivergence(Iterable itemPositions) { + final visibleIndices = itemPositions.map((it) => it.index).toList(); + + if (_markUnreadViewportSnapshot == null) { + _markUnreadViewportSnapshot = visibleIndices; + return; + } + if (_markUnreadViewportDiverged) return; + + const indicesEquality = UnorderedIterableEquality(); + if (!indicesEquality.equals(visibleIndices, _markUnreadViewportSnapshot)) { + _markUnreadViewportDiverged = true; + } + } + + // Marks divider A's anchor as seen once it renders on screen, or once the + // user scrolls past it without it ever rendering (a fast fling can skip + // intermediate frames). Sticky: never reverts once true, and reset only + // when the baseline is recaptured (channel change, or an explicit + // mark-unread — see [handleCurrentUserReadChanged]). + // + // Sessions started by an explicit mark-unread require scrolling *past* + // the anchor, since it starts out on screen — see + // [_unreadFromManualMarkUnread]. + // + // Returns true iff this call flips [_hasSeenFirstUnread] from false to + // true. + bool _maybeUpdateHasSeenFirstUnread(Iterable itemPositions) { + if (_isThreadConversation || _hasSeenFirstUnread.value) return false; + + final anchorId = _unreadDivider.value.anchorId; + if (anchorId == null) return false; + + final anchorMessageIndex = _messages().indexWhere((it) => it.id == anchorId); + if (anchorMessageIndex == -1) return false; + final anchorItemIndex = anchorMessageIndex + 2; + + final visibleIndices = itemPositions.map((position) => position.index).toList(); + if (visibleIndices.isEmpty) return false; + + // Smaller item indices are newer. That is a property of the + // index-to-message mapping (`messages[i - 2]`, newest first), not of the + // scroll direction, so it holds regardless of `config.reverse`. If even + // the newest visible item is older than the anchor, the anchor is no + // longer in view and the user has scrolled back past it into read + // history. + final isScrolledPast = visibleIndices.reduce(min) > anchorItemIndex; + + if (_unreadFromManualMarkUnread) { + // The anchor of a manual mark-unread is the message the user was + // looking at when they marked it, so it's on screen from the outset. + // Counting that sighting would dismiss the pill on the very next + // layout tick — the smallest scroll, or none at all. Only actually + // scrolling past the boundary retires it. + if (!isScrolledPast) return false; + } else if (!visibleIndices.contains(anchorItemIndex) && !isScrolledPast) { + return false; + } + + _hasSeenFirstUnread.value = true; + return true; + } + + // Marks messages as read if the conditions are met. + // + // In a thread: the parent must have at least one reply — the server-side + // thread object doesn't exist until the first reply lands, so + // `markThreadRead` on a reply-less parent 404s. A thread read is + // independent of where the parent channel's own loaded window sits. + // + // In the channel, all of: + // 1. The newest page is loaded (`isUpToDate`). + // 2. There is something unread to mark. + // 3. The bottom has been seen — either it's visible now ([isAtBottom]), + // or it was visible earlier and the user has since scrolled away + // (`hasSeenLastMessage`). + // 4. If there's an active manual mark-unread (`isMarkedAsUnread`), the + // viewport must genuinely differ from the one snapshotted when it + // was first observed (`_markUnreadViewportSnapshot`) — otherwise the + // anchor being immediately "visible" again (it's usually the very + // message just marked, with nothing yet scrolled) would undo the + // user's action instantly. + // 5. Divider A's anchor has actually been seen or scrolled past + // (`hasSeenFirstUnreadMessage`) — trivially satisfied when there is + // no boundary to see in the first place: the channel opened fully + // read, the user has never opened it at all (no `lastReadMessageId`, + // so the anchor could only resolve once top pagination reached the + // very start of the channel — effectively never for real history), + // or the channel uses local unread counts and so has no server read + // state to anchor against. + Future _maybeMarkMessagesAsRead({required bool isAtBottom}) async { + final channel = _channel(); + if (channel == null) return; + + final isInThread = _isThreadConversation; + + if (isInThread) { + // A server-side thread object only exists once the parent has at + // least one reply; markThreadRead on a reply-less parent returns 404. + if ((_parentMessage()?.replyCount ?? 0) == 0) return; + return _debouncedMarkMessagesAsRead(); + } + + final isUpToDate = channel.state?.isUpToDate ?? false; + if (!isUpToDate) return; + + final unreadCount = channel.state?.unreadCount ?? 0; + if (unreadCount <= 0) return; + + // True both when the channel opened fully read (no baseline) and when + // the user has never opened it (a baseline with no `lastReadMessageId`). + // Neither has a boundary the user could reach, so requiring one would + // leave the channel permanently unread — see condition 5 above. + final hasNoUnreadBoundary = _unreadBaselineCaptured && _unreadBaseline?.lastReadMessageId == null; + // Equivalent to `channel.usesLocalUnreadCount`, spelled out via + // `channel.client` rather than `Channel`'s private client field so it + // stays evaluable against a test double that only implements the public + // API surface. + final usesLocalUnreadCount = channel.client.isLocalUnreadCountEnabled && !channel.canUseReadReceipts; + final hasSeenFirstUnreadMessage = hasNoUnreadBoundary || _hasSeenFirstUnread.value || usesLocalUnreadCount; + final isMarkedAsUnread = channel.state?.isMarkedAsUnread ?? false; + final hasSeenLastMessage = _hasSeenLastMessage || isAtBottom; + + if (!hasSeenLastMessage) return; + if (!hasSeenFirstUnreadMessage) return; + + if (isMarkedAsUnread) { + // [handleItemPositionsChanged] already keeps this up to date on + // every position-changed tick; this call only matters as a fallback + // if this is ever reached some other way. See + // `_markUnreadViewportSnapshot`'s doc comment for why the guard has + // to latch on divergence rather than checking `isMarkedAsUnread` + // directly as a persistent gate. + _checkMarkUnreadViewportDivergence(_itemPositions()); + if (!_markUnreadViewportDiverged) return; + } + + // Everything the gate above reads is in the key, so an attempt is only + // skipped when repeating it could not produce a different outcome. + final attempt = ( + newestMessageId: _messages().firstOrNull?.id, + unreadCount: unreadCount, + isMarkedAsUnread: isMarkedAsUnread, + viewportDiverged: _markUnreadViewportDiverged, + ); + if (attempt == _lastMarkReadAttempt) return; + _lastMarkReadAttempt = attempt; + + await _debouncedMarkMessagesAsRead(); + _hasSeenLastMessage = false; + _markUnreadViewportSnapshot = null; + _markUnreadViewportDiverged = false; + } + + /// Cancels the pending debounced mark-reads and disposes the notifiers. + /// + /// The list must tear down anything that could still write to this + /// controller — stream subscriptions, position listeners — before calling + /// this. + void dispose() { + _disposed = true; + _debouncedMarkRead.cancel(); + _debouncedMarkThreadRead.cancel(); + _unreadDivider.dispose(); + _unreadDividerGrowth.dispose(); + _hasSeenFirstUnread.dispose(); + _scrollToBottomBadge.dispose(); + _hasLaidOut.dispose(); + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart index 55c27cfe60..3d3ddca42a 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:math'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; @@ -8,6 +7,7 @@ import 'package:rxdart/rxdart.dart'; import 'package:stream_chat_flutter/scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:stream_chat_flutter/src/message_list_view/floating_date_divider.dart'; import 'package:stream_chat_flutter/src/message_list_view/loading_indicator.dart'; +import 'package:stream_chat_flutter/src/message_list_view/message_list_unread_controller.dart'; import 'package:stream_chat_flutter/src/message_list_view/mlv_utils.dart'; import 'package:stream_chat_flutter/src/message_list_view/stream_message_list_empty_state.dart'; import 'package:stream_chat_flutter/src/message_list_view/stream_message_list_skeleton_loading.dart'; @@ -294,223 +294,20 @@ class _StreamMessageListViewState extends State { late final ItemPositionsListener _itemPositionListener; StreamChannelState? streamChannel; - // --- Divider A: pre-existing unread, frozen at channel open --- - // - // [_unreadBaseline] is the current user's [Read] captured once when the - // channel is attached (or on the first `currentUserReadStream` emission if - // read state wasn't available yet). It never changes afterwards, so - // resolving the anchor against it — rather than against the live, - // ever-shrinking `unreadCount` — is what keeps the divider and pill on - // screen across an auto mark-read. - Read? _unreadBaseline; - bool _unreadBaselineCaptured = false; - - // Resolved anchor for divider A. The anchor (and `count`, the frozen - // baseline used by the pill) is frozen once non-null: recomputation is - // skipped as soon as `anchorId` is set. May take a few rebuilds to resolve - // if top pagination hasn't finished loading the boundary yet. - final _unreadDivider = ValueNotifier<({int count, String? anchorId})>((count: 0, anchorId: null)); - - // Grows by one for every message that arrives out of view while divider - // A is on screen, so the divider's displayed count keeps counting up - // during the session (mirroring WhatsApp) instead of staying frozen at - // the open-time count. Added on top of `_unreadDivider.value.count` for - // display only — the pill keeps using the frozen count. - final ValueNotifier _unreadDividerGrowth = ValueNotifier(0); - - // Sticky: becomes true once the user has seen (rendered) or scrolled past - // divider A's anchor. Drives the pill's permanent dismissal and (see - // [_maybeMarkMessagesAsRead]) gates auto mark-read. - final ValueNotifier _hasSeenFirstUnread = ValueNotifier(false); - - // Whether the list has reported item positions at least once. - // - // The pill waits on this. Its count is published synchronously while the - // channel is attached, but `_hasSeenFirstUnread` can only be decided from - // item positions, which arrive in a post-frame callback — so without this - // the pill paints for exactly one frame on every channel opened at its - // first unread message, then disappears. - final ValueNotifier _hasLaidOut = ValueNotifier(false); - - // Scroll-to-bottom badge count. Counts messages that arrive while the user - // is scrolled away from the bottom; resets to 0 once they reach the bottom. - final ValueNotifier _scrollToBottomBadge = ValueNotifier(0); - - // Sticky "bottom was reached" flag for the mark-read gate. Cleared after - // each successful mark-read so returning to the bottom is required again - // before the next one. - bool _hasSeenLastMessage = false; - - // While non-null, the viewport captured at the moment an active manual - // mark-unread (`channel.state.isMarkedAsUnread`) was first observed. - // `_maybeMarkMessagesAsRead` blocks until [_markUnreadViewportDiverged] - // is true — evidence the user did something (scrolled, reopened the - // channel, etc.) since marking the message unread, rather than the - // anchor merely being immediately "visible" again because it's usually - // the very message just marked and nothing has moved. - // - // This can't gate on `isMarkedAsUnread` directly and permanently: that - // flag only clears via a successful mark-read, which is the very thing - // it would be gating, so treating it as a persistent block would - // deadlock the channel unread forever the moment it's set — the exact - // bug this snapshot exists to avoid. - // - // Set eagerly in [_handleCurrentUserReadChanged] right when a live - // transition is observed (captures the precise pre-scroll viewport), and - // in [_handleItemPositionsChanged] on the first genuinely laid-out frame - // as a fallback for when the channel simply mounts with - // `isMarkedAsUnread` already true and no transition ever fires — that - // has to happen there and not lazily inside [_maybeMarkMessagesAsRead], - // since the first time that gate is evaluated might already be the - // user's first genuine arrival at the bottom, which would otherwise be - // burned on capturing the baseline instead of acting on it. Cleared once - // a mark-read actually goes through, or once `isMarkedAsUnread` itself - // clears (so a future mark-unread starts its own fresh snapshot). - // - // Holds visible item *indices* rather than full [ItemPosition]s: comparing - // full positions would latch divergence on a sub-pixel edge change from an - // unrelated relayout (async attachment sizing, keyboard inset, image - // load) even though the user never scrolled, undoing the manual - // mark-unread almost instantly. - List? _markUnreadViewportSnapshot; - - // Sticky once true: sighted the first time [_handleItemPositionsChanged] - // (or, as a fallback, [_maybeMarkMessagesAsRead] itself) sees item - // positions that genuinely differ from [_markUnreadViewportSnapshot]. - // Deliberately tracked as "did this ever happen" rather than - // re-comparing the *current* positions against the snapshot on each - // check — a user who scrolls away and back settles at the exact same - // rest position, which would otherwise look unchanged and re-block a - // mark-read that should already have been earned by that round trip. - bool _markUnreadViewportDiverged = false; - - // State the last mark-read attempt was made against. Item positions tick - // on every scroll frame, so without this a mark-read that keeps failing - // would be retried for as long as the user keeps scrolling at the bottom - // (once a second, as bounded by the debounce). Every input the gate in - // [_maybeMarkMessagesAsRead] actually reads is part of the key, so a - // genuine change — a new message, a mark-unread, the viewport diverging - // after one — still gets its attempt. - ({String? newestMessageId, int unreadCount, bool isMarkedAsUnread, bool viewportDiverged})? _lastMarkReadAttempt; - - // Previous value of `channel.state.isMarkedAsUnread`, so - // [_handleCurrentUserReadChanged] can act on a new mark-unread rather than - // on every read-stream emission that happens while the flag stays set. - // Seeded from the channel on attach, since it can already be set there. - bool _wasMarkedAsUnread = false; - - // Read boundary observed alongside [_wasMarkedAsUnread]. A mark-unread - // moves the boundary backward, so a change here while the flag is already - // set is how a *second* mark-unread is told apart from the read-stream - // emissions that keep arriving during one. - ({DateTime? lastRead, String? lastReadMessageId})? _lastReadBoundary; - - static ({DateTime? lastRead, String? lastReadMessageId})? _readBoundaryOf(Read? read) { - if (read == null) return null; - return (lastRead: read.lastRead, lastReadMessageId: read.lastReadMessageId); - } - - // Whether divider A's current session came from an explicit mark-unread - // rather than from pre-existing unread at channel open. The anchor of a - // manual mark-unread is the message the user was looking at when they - // marked it, so it's already on screen — see - // [_maybeUpdateHasSeenFirstUnread] for why that changes what counts as - // having reached the boundary. - bool _unreadFromManualMarkUnread = false; - - // Captures [_unreadBaseline] the first time the current user's read state - // becomes available, then attempts to resolve divider A's anchor against - // it. No-ops in a thread, where divider A doesn't apply. - void _captureUnreadBaselineIfNeeded() { - if (_unreadBaselineCaptured || _isThreadConversation) return; - - final currentUserRead = streamChannel?.channel.state?.currentUserRead; - if (currentUserRead == null) return; - - _unreadBaselineCaptured = true; - _unreadBaseline = currentUserRead.unreadMessages > 0 ? currentUserRead : null; - // Publish the frozen count right away, even though the anchor itself - // can't resolve until top pagination has loaded that far back — the - // pill only needs the count, not the anchor, so it shouldn't wait on - // pagination to appear (see `_onUnreadPillJumpTap` for how a tap - // before the anchor resolves still jumps there). - if (_unreadBaseline case final baseline?) { - _unreadDivider.value = (count: baseline.unreadMessages, anchorId: _unreadDivider.value.anchorId); - } - _resolveUnreadDivider(); - } - - // Resolves divider A's anchor against the frozen baseline. A no-op once - // resolved, and while top pagination hasn't loaded the boundary yet. - void _resolveUnreadDivider() { - if (_isThreadConversation || _unreadDivider.value.anchorId != null) return; - - final baseline = _unreadBaseline; - if (baseline == null) return; - - final anchor = streamChannel?.getFirstUnreadMessage(baseline); - if (anchor == null) return; - - _unreadDivider.value = (count: baseline.unreadMessages, anchorId: anchor.id); - } - - // Reacts to a `currentUserReadStream` emission. An explicit mark-unread - // moves the read boundary backward — treat it as a new session start for - // divider A/the pill. - // - // The reset is deliberately gated on a *new* mark-unread — the flag - // turning on, or the read boundary moving again while it's already on — - // rather than on the flag merely being set: the read stream also emits - // while it stays set (every new message, for one), and re-running the - // reset then would clear `_hasSeenFirstUnread` again and flicker the pill - // back in and straight out on each arrival. An arriving message bumps - // `unreadMessages` but leaves the boundary untouched, so gating on the - // boundary avoids that flicker while still catching a second mark-unread. - // - // Watching the boundary rather than only the transition matters because - // `isMarkedAsUnread` is cleared solely by a mark-read (see - // `ChannelClientState.markReadLocally`), and both - // [_captureUnreadBaselineIfNeeded] and [_resolveUnreadDivider] freeze once - // resolved — so this reset is the only thing that can move divider A once - // a mark-unread session is under way. - void _handleCurrentUserReadChanged() { - if (_isThreadConversation) return; - - final channel = streamChannel?.channel; - if (channel == null) return; - - final isMarkedAsUnread = channel.state?.isMarkedAsUnread ?? false; - final readBoundary = _readBoundaryOf(channel.state?.currentUserRead); - final boundaryMoved = readBoundary != _lastReadBoundary; - final justMarkedAsUnread = isMarkedAsUnread && (!_wasMarkedAsUnread || boundaryMoved); - _wasMarkedAsUnread = isMarkedAsUnread; - _lastReadBoundary = readBoundary; - - if (justMarkedAsUnread) { - _unreadBaselineCaptured = false; - _unreadBaseline = null; - _unreadDivider.value = (count: 0, anchorId: null); - _unreadDividerGrowth.value = 0; - _hasSeenFirstUnread.value = false; - _unreadFromManualMarkUnread = true; - // Each mark-unread starts its own session, so the snapshot is taken - // fresh here rather than kept from a previous one — but only from a - // viewport that has actually been laid out. `itemPositions` is still - // empty before the first frame, and capturing that would make the - // very first laid-out frame look like divergence, defeating guard 4 - // and leaving the fallback in [_handleItemPositionsChanged] - // unreachable. Left null instead, for that fallback to fill in. - final visibleIndices = _itemPositionListener.itemPositions.value.map((it) => it.index).toList(); - _markUnreadViewportSnapshot = visibleIndices.isEmpty ? null : visibleIndices; - _markUnreadViewportDiverged = false; - } else if (!isMarkedAsUnread) { - _unreadFromManualMarkUnread = false; - _markUnreadViewportSnapshot = null; - _markUnreadViewportDiverged = false; - } - - _captureUnreadBaselineIfNeeded(); - } + // Owns every piece of unread state: the unread messages divider and its + // floating pill, the scroll-to-bottom badge count, and the auto mark-read + // gate. Signals are forwarded to it from the listeners below; the build + // methods render from the listenables it exposes. + late final _unreadController = MessageListUnreadController( + channel: () => streamChannel?.channel, + getFirstUnreadMessage: (read) => streamChannel?.getFirstUnreadMessage(read), + parentMessage: () => widget.parentMessage, + messages: () => messages, + itemPositions: () => _itemPositionListener.itemPositions.value, + markReadWhenAtTheBottom: () => widget.config.markReadWhenAtTheBottom, + scrollToMessage: (id) => _scrollToMessage(messageId: id, highlight: false), + attachToken: () => streamChannel, + ); bool get _upToDate => streamChannel!.channel.state!.isUpToDate; @@ -563,34 +360,14 @@ class _StreamMessageListViewState extends State { if (newStreamChannel != streamChannel) { streamChannel = newStreamChannel; - debouncedMarkRead.cancel(); - debouncedMarkThreadRead.cancel(); - final newChannelState = newStreamChannel.channel.state; - _unreadBaselineCaptured = false; - _unreadBaseline = null; - _unreadDivider.value = (count: 0, anchorId: null); - _unreadDividerGrowth.value = 0; - _scrollToBottomBadge.value = 0; - _hasSeenFirstUnread.value = false; - _hasSeenLastMessage = false; _showScrollToBottom.value = false; - _hasLaidOut.value = false; - _lastMarkReadAttempt = null; - _markUnreadViewportSnapshot = null; - _markUnreadViewportDiverged = false; - // Seeded from the channel's own state rather than hardcoded to false: - // a channel can mount with a manual mark-unread already active (mark - // unread, leave, come back — the flag lives on the cached - // `ChannelClientState`). `currentUserReadStream` is backed by a - // `BehaviorSubject`, so the subscription below replays the current - // value straight away; without this seed that replay would read as a - // brand-new mark-unread and restart a session that never ended. - _wasMarkedAsUnread = newChannelState?.isMarkedAsUnread ?? false; - _lastReadBoundary = _readBoundaryOf(newChannelState?.currentUserRead); - _unreadFromManualMarkUnread = _wasMarkedAsUnread; - _captureUnreadBaselineIfNeeded(); + // Runs after `streamChannel` is reassigned (so the controller resolves + // the new channel) and before the read-stream subscription below, whose + // `BehaviorSubject` replays straight away — the reset seeds the state + // that replay is checked against. + _unreadController.attach(); final highlightInitialMessage = widget.config.highlightInitialMessage; final highlightMessageId = switch ((highlightInitialMessage, _isThreadConversation)) { @@ -621,23 +398,11 @@ class _StreamMessageListViewState extends State { // landing while the user happens to be mid-drag or mid-fling is // exactly what the badge and divider exist to report, and bailing // first would drop it from both counts permanently. - // - // Qualifying arrivals are filtered the same way the channel's own - // unread count filters them, so silent, shadowed, ephemeral, - // thread-only, restricted, muted-sender and own messages don't - // inflate either counter. The badge and divider also only apply to - // the channel's message stream, never to thread replies. - final countsAsUnread = _countsTowardsUnreadIndicators(message, currentUser); - if (!_isThreadConversation && countsAsUnread) { - // The divider counts every qualifying arrival — including ones - // seen live at the bottom — so it keeps counting up like - // WhatsApp's. The badge is narrower: it only exists to flag - // what was missed while scrolled away, so it skips arrivals - // that were already in view and resets once the bottom is - // reached (see `_handleItemPositionsChanged`). - _unreadDividerGrowth.value += 1; - if (!isAtBottom) _scrollToBottomBadge.value += 1; - } + _unreadController.handleMessageArrived( + message, + currentUser: currentUser, + isAtBottom: isAtBottom, + ); // Don't fight a scroll already in motion (drag, fling, or // still-running animated scrollTo). @@ -668,7 +433,7 @@ class _StreamMessageListViewState extends State { _userReadListener?.cancel(); _userReadListener = state?.currentUserReadStream.listen((_) { - _handleCurrentUserReadChanged(); + _unreadController.handleCurrentUserReadChanged(); }); } } @@ -682,14 +447,8 @@ class _StreamMessageListViewState extends State { _userReadListener?.cancel(); _userReadListener = null; _itemPositionListener.itemPositions.removeListener(_handleItemPositionsChanged); - debouncedMarkRead.cancel(); - debouncedMarkThreadRead.cancel(); - _unreadDivider.dispose(); - _unreadDividerGrowth.dispose(); - _hasSeenFirstUnread.dispose(); - _scrollToBottomBadge.dispose(); + _unreadController.dispose(); _showScrollToBottom.dispose(); - _hasLaidOut.dispose(); _highlightState.dispose(); super.dispose(); } @@ -856,9 +615,9 @@ class _StreamMessageListViewState extends State { // settles. Deferred (not synchronous) since mutating a [ValueNotifier] // read by a [ValueListenableBuilder] further down this same build would // notify a listener that hasn't rebuilt yet this frame. - if (_unreadBaseline != null && _unreadDivider.value.anchorId == null) { + if (_unreadController.needsAnchorResolution) { WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) _resolveUnreadDivider(); + if (mounted) _unreadController.resolveDividerAnchor(); }); } @@ -1088,7 +847,7 @@ class _StreamMessageListViewState extends State { Positioned( top: context.streamSpacing.sm, child: ValueListenableBuilder( - valueListenable: _unreadDivider, + valueListenable: _unreadController.unreadDivider, builder: (context, unread, _) { // Gated on the frozen count, not the anchor: the count is // known immediately from the baseline `Read`, while the @@ -1100,7 +859,7 @@ class _StreamMessageListViewState extends State { // scrolled most of the way there themselves. if (unread.count <= 0) return const Empty(); return ValueListenableBuilder( - valueListenable: _hasLaidOut, + valueListenable: _unreadController.hasLaidOut, builder: (context, laidOut, ___) { // Item positions decide whether the boundary is already // on screen, and they only arrive after the first frame @@ -1109,13 +868,13 @@ class _StreamMessageListViewState extends State { // unread message — which is the default. if (!laidOut) return const Empty(); return ValueListenableBuilder( - valueListenable: _hasSeenFirstUnread, + valueListenable: _unreadController.hasSeenFirstUnread, builder: (context, seen, __) { if (seen) return const Empty(); return UnreadIndicatorButton( unreadCount: unread.count, - onJumpTap: (_) => _onUnreadPillJumpTap(), - onDismissTap: _onUnreadPillDismissTap, + onJumpTap: (_) => _unreadController.onPillJumpTapped(), + onDismissTap: _unreadController.onPillDismissTapped, ); }, ); @@ -1180,11 +939,11 @@ class _StreamMessageListViewState extends State { }) { if (_isThreadConversation) return separator; return ValueListenableBuilder( - valueListenable: _unreadDivider, + valueListenable: _unreadController.unreadDivider, builder: (context, unread, _) { if (unread.anchorId != message.id) return separator; return ValueListenableBuilder( - valueListenable: _unreadDividerGrowth, + valueListenable: _unreadController.unreadDividerGrowth, builder: (context, growth, __) => Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, @@ -1215,88 +974,6 @@ class _StreamMessageListViewState extends State { } } - Future _onUnreadPillJumpTap() async { - // The anchor may not have resolved yet if top pagination hasn't loaded - // that far back — the pill is visible already (see its gating above), - // so fall back to the frozen baseline's own last-read boundary, known - // immediately from the server `Read`, rather than doing nothing. - final anchorId = _unreadDivider.value.anchorId ?? _unreadBaseline?.lastReadMessageId; - - // A channel the user has never opened reports unread messages but has - // no read boundary at all: the anchor can't resolve until top - // pagination ends, and there's no `lastReadMessageId` to fall back on - // either. Everything loaded is unread, so head for the oldest message - // currently loaded — as far back as the boundary can be, and it pulls - // the next page in on arrival — rather than leaving the tap inert. - // - // `_hasSeenFirstUnread` is deliberately not latched here: the real - // boundary is further back than where this lands, so the pill stays up - // until it's genuinely reached. - if (anchorId == null) { - final oldestLoaded = messages.lastOrNull; - if (oldestLoaded == null) return; - await _scrollToMessage(messageId: oldestLoaded.id, highlight: false); - return; - } - - // Delegates to [_scrollToMessage], which falls back to - // [StreamChannelState.loadChannelAtMessage] when the anchor isn't in the - // currently loaded window — after which the real anchor resolves - // naturally via the retry in [_buildListView], rendering divider A too. - // `_scrollToMessage` can await pagination and a frame, and this same - // `State` survives a channel change — so remember which channel the tap - // was for and drop the result if it isn't the current one any more. - final tappedFor = streamChannel; - final didJump = await _scrollToMessage(messageId: anchorId, highlight: false); - if (!mounted || streamChannel != tappedFor) return; - // Only claim the boundary as seen once the jump actually landed — - // otherwise (message not found even after pagination, or the SPL not - // attached) the pill would vanish and the mark-read gate would open for - // a boundary the user never actually reached. - if (didJump) _hasSeenFirstUnread.value = true; - } - - Future _onUnreadPillDismissTap() async { - _hasSeenFirstUnread.value = true; - // Dismissing is a local decision; if the request behind it fails there - // is nothing to show the user, and letting it escape here would surface - // as an unhandled async error instead. - _markMessagesAsRead().ignore(); - } - - late final debouncedMarkRead = debounce( - ([String? id]) => streamChannel?.channel.markRead(messageId: id), - const Duration(seconds: 1), - leading: true, - ); - - late final debouncedMarkThreadRead = debounce( - (String parentId) => streamChannel?.channel.markThreadRead(parentId), - const Duration(seconds: 1), - leading: true, - ); - - Future _markMessagesAsRead() async { - if (widget.parentMessage case final parent?) { - // If we are in a thread, mark the thread as read immediately. - await streamChannel?.channel.markThreadRead(parent.id); - return; - } - - // Otherwise, mark the channel as read immediately. - await streamChannel?.channel.markRead(); - } - - Future _debouncedMarkMessagesAsRead() async { - if (widget.parentMessage case final parent?) { - // If we are in a thread, mark the thread as read. - debouncedMarkThreadRead.call([parent.id]); - } else { - // Otherwise, mark the channel as read. - debouncedMarkRead.call(); - } - } - // Determines the applicable [SpacingType]s between two adjacent messages. // // Returns `null` when the messages fall on different days, indicating a @@ -1398,7 +1075,7 @@ class _StreamMessageListViewState extends State { Widget _buildScrollToBottom() { return ValueListenableBuilder( - valueListenable: _scrollToBottomBadge, + valueListenable: _unreadController.scrollToBottomBadge, builder: (_, badgeCount, __) { if (widget.builders.scrollToBottomButton case final builder?) { return builder(badgeCount, scrollToBottomDefaultTapAction); @@ -1527,76 +1204,12 @@ class _StreamMessageListViewState extends State { return _maybeWrapWithHighlight(message: message, child: layout); } - // Whether a freshly-arrived [message] should bump the scroll-to-bottom - // badge and divider A's growing count. - // - // This is the message- and sender-level half of - // [MessageRules.canCountAsUnread], which is what keeps silent, shadowed, - // ephemeral, thread-only, restricted, muted-sender and own messages from - // inflating either counter. - // - // The channel-level half of that rule (`isMuted`, `canUseReadReceipts`, - // `usesLocalUnreadCount`) is deliberately left out. Those govern whether - // the server tracks an unread count for the channel at all, whereas these - // two counters are purely local "what arrived while you weren't looking" - // indicators that should keep working either way — and - // `usesLocalUnreadCount` is an extension getter reading `Channel`'s - // private client field, so it can't be resolved against a channel double - // at all. - // - // The user-level half (`isReadReceiptsEnabled`) is *not* left out: it - // isn't about how the channel is configured but about the user opting out - // of unread tracking entirely, and honouring it here is what keeps these - // indicators from counting up while the channel itself reports zero. - bool _countsTowardsUnreadIndicators(Message message, OwnUser? currentUser) { - if (currentUser == null) return false; - if (!currentUser.isReadReceiptsEnabled) return false; - - if (message.silent) return false; - if (message.shadowed) return false; - if (message.isEphemeral) return false; - - // Thread replies don't count towards the channel's unread state unless - // they were explicitly also sent to the channel. - if (message.parentId != null && message.showInChannel != true) return false; - - final sender = message.user; - if (sender == null) return false; - if (sender.id == currentUser.id) return false; - - if (message.isNotVisibleTo(currentUser.id)) return false; - - final isSenderMuted = currentUser.mutes.any((it) => it.target.id == sender.id); - if (isSenderMuted) return false; - - return true; - } - void _handleItemPositionsChanged() { if (!mounted) return; final itemPositions = _itemPositionListener.itemPositions.value; if (itemPositions.isEmpty) return; - _hasLaidOut.value = true; - - // Snapshot the viewport (or check it against an existing snapshot for - // divergence) the first time it's genuinely laid out while marked as - // unread, in case the channel simply mounted in that state rather than - // [_handleCurrentUserReadChanged] observing a live transition to hook - // the snapshot on. Doing this here — on every non-empty layout, before - // checking anything else below — rather than lazily inside - // [_maybeMarkMessagesAsRead], matters: that gate is only ever evaluated - // when a mark-read could fire, which for a channel the user opens and - // immediately scrolls all the way through might be the very first time - // they reach the bottom. Capturing the baseline there would burn that - // first genuine read on the snapshot itself instead of acting on it. - if (streamChannel?.channel.state?.isMarkedAsUnread ?? false) { - _checkMarkUnreadViewportDivergence(itemPositions); - } - - final justSeenFirstUnread = _maybeUpdateHasSeenFirstUnread(itemPositions); - // Index of the last item in the list view is 2 as 1 is the progress // indicator and 0 is the footer. const lastItemIndex = 2; @@ -1612,184 +1225,10 @@ class _StreamMessageListViewState extends State { } _showScrollToBottom.value = !isLastItemFullyVisible; - if (isLastItemFullyVisible) { - _hasSeenLastMessage = true; - _scrollToBottomBadge.value = 0; - } - - // Attempt a mark-read whenever either half of the gate could have just - // become satisfied; `_maybeMarkMessagesAsRead` does the actual deciding. - if ((isLastItemFullyVisible || justSeenFirstUnread) && widget.config.markReadWhenAtTheBottom) { - _maybeMarkMessagesAsRead().ignore(); - } - } - - // Captures [_markUnreadViewportSnapshot] the first time this is called, - // and otherwise checks [itemPositions] against it, latching - // [_markUnreadViewportDiverged] the first time they genuinely differ. - // Deliberately latching rather than re-comparing *current* positions - // against the snapshot on every check: a user who scrolls away and back - // settles at the exact same rest position, which would otherwise look - // unchanged and re-block a mark-read the round trip should already have - // earned. Safe to call on every position-changed tick — a no-op once - // already diverged. - // - // Compares the set of visible item *indices* rather than full - // [ItemPosition]s (which also carry leading/trailing edge offsets) — an - // unrelated relayout that nudges an edge by a fraction of a pixel isn't - // evidence the user did anything, and shouldn't count as divergence. - void _checkMarkUnreadViewportDivergence(Iterable itemPositions) { - final visibleIndices = itemPositions.map((it) => it.index).toList(); - - if (_markUnreadViewportSnapshot == null) { - _markUnreadViewportSnapshot = visibleIndices; - return; - } - if (_markUnreadViewportDiverged) return; - - const indicesEquality = UnorderedIterableEquality(); - if (!indicesEquality.equals(visibleIndices, _markUnreadViewportSnapshot)) { - _markUnreadViewportDiverged = true; - } - } - - // Marks divider A's anchor as seen once it renders on screen, or once the - // user scrolls past it without it ever rendering (a fast fling can skip - // intermediate frames). Sticky: never reverts once true, and reset only - // when the baseline is recaptured (channel change, or an explicit - // mark-unread — see [_handleCurrentUserReadChanged]). - // - // Sessions started by an explicit mark-unread require scrolling *past* - // the anchor, since it starts out on screen — see - // [_unreadFromManualMarkUnread]. - // - // Returns true iff this call flips [_hasSeenFirstUnread] from false to - // true. - bool _maybeUpdateHasSeenFirstUnread(Iterable itemPositions) { - if (_isThreadConversation || _hasSeenFirstUnread.value) return false; - - final anchorId = _unreadDivider.value.anchorId; - if (anchorId == null) return false; - - final anchorMessageIndex = messages.indexWhere((it) => it.id == anchorId); - if (anchorMessageIndex == -1) return false; - final anchorItemIndex = anchorMessageIndex + 2; - - final visibleIndices = itemPositions.map((position) => position.index).toList(); - if (visibleIndices.isEmpty) return false; - - // Smaller item indices are newer. That is a property of the - // index-to-message mapping (`messages[i - 2]`, newest first), not of the - // scroll direction, so it holds regardless of `config.reverse`. If even - // the newest visible item is older than the anchor, the anchor is no - // longer in view and the user has scrolled back past it into read - // history. - final isScrolledPast = visibleIndices.reduce(min) > anchorItemIndex; - - if (_unreadFromManualMarkUnread) { - // The anchor of a manual mark-unread is the message the user was - // looking at when they marked it, so it's on screen from the outset. - // Counting that sighting would dismiss the pill on the very next - // layout tick — the smallest scroll, or none at all. Only actually - // scrolling past the boundary retires it. - if (!isScrolledPast) return false; - } else if (!visibleIndices.contains(anchorItemIndex) && !isScrolledPast) { - return false; - } - - _hasSeenFirstUnread.value = true; - return true; - } - - // Marks messages as read if the conditions are met. - // - // In a thread: the parent must have at least one reply — the server-side - // thread object doesn't exist until the first reply lands, so - // `markThreadRead` on a reply-less parent 404s. A thread read is - // independent of where the parent channel's own loaded window sits. - // - // In the channel, all of: - // 1. The newest page is loaded (`isUpToDate`). - // 2. There is something unread to mark. - // 3. The bottom has been seen — either it's visible now, or it was - // visible earlier and the user has since scrolled away - // (`hasSeenLastMessage`). - // 4. If there's an active manual mark-unread (`isMarkedAsUnread`), the - // viewport must genuinely differ from the one snapshotted when it - // was first observed (`_markUnreadViewportSnapshot`) — otherwise the - // anchor being immediately "visible" again (it's usually the very - // message just marked, with nothing yet scrolled) would undo the - // user's action instantly. - // 5. Divider A's anchor has actually been seen or scrolled past - // (`hasSeenFirstUnreadMessage`) — trivially satisfied when there is - // no boundary to see in the first place: the channel opened fully - // read, the user has never opened it at all (no `lastReadMessageId`, - // so the anchor could only resolve once top pagination reached the - // very start of the channel — effectively never for real history), - // or the channel uses local unread counts and so has no server read - // state to anchor against. - Future _maybeMarkMessagesAsRead() async { - final channel = streamChannel?.channel; - if (channel == null) return; - - final isInThread = widget.parentMessage != null; - - if (isInThread) { - // A server-side thread object only exists once the parent has at - // least one reply; markThreadRead on a reply-less parent returns 404. - if ((widget.parentMessage?.replyCount ?? 0) == 0) return; - return _debouncedMarkMessagesAsRead(); - } - - final isUpToDate = channel.state?.isUpToDate ?? false; - if (!isUpToDate) return; - - final unreadCount = channel.state?.unreadCount ?? 0; - if (unreadCount <= 0) return; - - // True both when the channel opened fully read (no baseline) and when - // the user has never opened it (a baseline with no `lastReadMessageId`). - // Neither has a boundary the user could reach, so requiring one would - // leave the channel permanently unread — see condition 5 above. - final hasNoUnreadBoundary = _unreadBaselineCaptured && _unreadBaseline?.lastReadMessageId == null; - // Equivalent to `channel.usesLocalUnreadCount`, spelled out via - // `channel.client` rather than `Channel`'s private client field so it - // stays evaluable against a test double that only implements the public - // API surface. - final usesLocalUnreadCount = channel.client.isLocalUnreadCountEnabled && !channel.canUseReadReceipts; - final hasSeenFirstUnreadMessage = hasNoUnreadBoundary || _hasSeenFirstUnread.value || usesLocalUnreadCount; - final isMarkedAsUnread = channel.state?.isMarkedAsUnread ?? false; - final hasSeenLastMessage = _hasSeenLastMessage || !_showScrollToBottom.value; - - if (!hasSeenLastMessage) return; - if (!hasSeenFirstUnreadMessage) return; - - if (isMarkedAsUnread) { - // `_handleItemPositionsChanged` already keeps this up to date on - // every position-changed tick; this call only matters as a fallback - // if this is ever reached some other way. See - // `_markUnreadViewportSnapshot`'s doc comment for why the guard has - // to latch on divergence rather than checking `isMarkedAsUnread` - // directly as a persistent gate. - _checkMarkUnreadViewportDivergence(_itemPositionListener.itemPositions.value); - if (!_markUnreadViewportDiverged) return; - } - - // Everything the gate above reads is in the key, so an attempt is only - // skipped when repeating it could not produce a different outcome. - final attempt = ( - newestMessageId: messages.firstOrNull?.id, - unreadCount: unreadCount, - isMarkedAsUnread: isMarkedAsUnread, - viewportDiverged: _markUnreadViewportDiverged, + _unreadController.handleItemPositionsChanged( + itemPositions, + isAtBottom: isLastItemFullyVisible, ); - if (attempt == _lastMarkReadAttempt) return; - _lastMarkReadAttempt = attempt; - - await _debouncedMarkMessagesAsRead(); - _hasSeenLastMessage = false; - _markUnreadViewportSnapshot = null; - _markUnreadViewportDiverged = false; } void _getOnThreadTap() { diff --git a/packages/stream_chat_flutter/test/src/message_list_view/message_list_unread_controller_test.dart b/packages/stream_chat_flutter/test/src/message_list_view/message_list_unread_controller_test.dart new file mode 100644 index 0000000000..57468260ed --- /dev/null +++ b/packages/stream_chat_flutter/test/src/message_list_view/message_list_unread_controller_test.dart @@ -0,0 +1,650 @@ +// Unit tests for [MessageListUnreadController], the unread state machine +// behind StreamMessageListView. +// +// The widget-level behaviour is already locked by mark_read_test.dart and +// unread_divider_test.dart; these tests target the branches that are awkward +// to reach through a pumped widget — the arrival filter matrix, each +// individual condition of the mark-read gate, the attempt-dedupe key, the +// mark-unread viewport divergence latch, and the pill's jump fallbacks. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat_flutter/scrollable_positioned_list/scrollable_positioned_list.dart'; +import 'package:stream_chat_flutter/src/message_list_view/message_list_unread_controller.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import '../mocks.dart'; + +void main() { + late MockClient client; + late MockChannel channel; + late MockChannelState channelState; + late OwnUser ownUser; + + // Mutable fixture the controller reads through its injected accessors. + late List messages; + late Message? parentMessage; + late List itemPositions; + late bool markReadWhenAtTheBottom; + late Message? firstUnreadMessage; + late Object? attachToken; + + // Records every scroll the controller asks for, and controls whether the + // scroll is reported as having landed. + late List scrollRequests; + late bool scrollLands; + + MessageListUnreadController buildController() { + final controller = MessageListUnreadController( + channel: () => channel, + getFirstUnreadMessage: (_) => firstUnreadMessage, + parentMessage: () => parentMessage, + messages: () => messages, + itemPositions: () => itemPositions, + markReadWhenAtTheBottom: () => markReadWhenAtTheBottom, + scrollToMessage: (id) async { + scrollRequests.add(id); + return scrollLands; + }, + attachToken: () => attachToken, + ); + addTearDown(controller.dispose); + return controller; + } + + Message message({ + String id = 'msg-1', + User? user, + bool silent = false, + bool shadowed = false, + String? parentId, + bool? showInChannel, + String? type, + List? restrictedVisibility, + }) { + return Message( + id: id, + text: 'hello', + user: user ?? User(id: 'other'), + silent: silent, + shadowed: shadowed, + parentId: parentId, + showInChannel: showInChannel, + type: type ?? MessageType.regular, + restrictedVisibility: restrictedVisibility, + ); + } + + // A viewport showing the given item indices, all fully visible. + List viewport(List indices) { + return [ + for (final index in indices) ItemPosition(index: index, itemLeadingEdge: 0.1, itemTrailingEdge: 0.2), + ]; + } + + // Delivers a positions tick, keeping the injected `itemPositions` in step + // with what is handed to the controller — the list reads both from the same + // listener, so a test that only passed one would let the gate's fallback + // divergence check compare against a viewport that never existed. + void tick( + MessageListUnreadController controller, + List indices, { + required bool isAtBottom, + }) { + itemPositions = viewport(indices); + controller.handleItemPositionsChanged(itemPositions, isAtBottom: isAtBottom); + } + + Read read({ + DateTime? lastRead, + String? lastReadMessageId, + int unreadMessages = 0, + }) { + return Read( + user: ownUser, + lastRead: lastRead ?? DateTime.utc(2024), + lastReadMessageId: lastReadMessageId, + unreadMessages: unreadMessages, + ); + } + + setUp(() { + client = MockClient(); + // `canUseReadReceipts` is an extension getter over `ownCapabilities`, so + // it is granted through the capability rather than stubbed. + channel = MockChannel(ownCapabilities: const [ChannelCapability.readEvents]); + channelState = MockChannelState(); + ownUser = OwnUser(id: 'ownid'); + + when(() => channel.client).thenReturn(client); + when(() => channel.state).thenReturn(channelState); + when(() => client.isLocalUnreadCountEnabled).thenReturn(false); + when(() => channel.markRead()).thenAnswer((_) async => EmptyResponse()); + when(() => channel.markRead(messageId: any(named: 'messageId'))).thenAnswer((_) async => EmptyResponse()); + when(() => channel.markThreadRead(any())).thenAnswer((_) async => EmptyResponse()); + when(() => channelState.currentUserRead).thenReturn(null); + + messages = []; + parentMessage = null; + itemPositions = []; + markReadWhenAtTheBottom = true; + firstUnreadMessage = null; + attachToken = 'channel-1'; + scrollRequests = []; + scrollLands = true; + }); + + group('message arrivals', () { + test('a qualifying arrival bumps the divider growth and the badge', () { + final controller = buildController()..handleMessageArrived(message(), currentUser: ownUser, isAtBottom: false); + + expect(controller.unreadDividerGrowth.value, 1); + expect(controller.scrollToBottomBadge.value, 1); + }); + + test('an arrival seen at the bottom counts for the divider but not the badge', () { + final controller = buildController()..handleMessageArrived(message(), currentUser: ownUser, isAtBottom: true); + + expect(controller.unreadDividerGrowth.value, 1); + expect(controller.scrollToBottomBadge.value, 0); + }); + + test('nothing counts in a thread', () { + parentMessage = message(id: 'parent'); + final controller = buildController()..handleMessageArrived(message(), currentUser: ownUser, isAtBottom: false); + + expect(controller.unreadDividerGrowth.value, 0); + expect(controller.scrollToBottomBadge.value, 0); + }); + + test('nothing counts without a current user', () { + final controller = buildController()..handleMessageArrived(message(), currentUser: null, isAtBottom: false); + + expect(controller.unreadDividerGrowth.value, 0); + expect(controller.scrollToBottomBadge.value, 0); + }); + + test('nothing counts while the user has read receipts disabled', () { + final controller = buildController(); + final optedOut = OwnUser( + id: 'ownid', + privacySettings: const PrivacySettings(readReceipts: ReadReceipts(enabled: false)), + ); + + controller.handleMessageArrived(message(), currentUser: optedOut, isAtBottom: false); + + expect(controller.unreadDividerGrowth.value, 0); + expect(controller.scrollToBottomBadge.value, 0); + }); + + test("the current user's own messages do not count", () { + final controller = buildController() + ..handleMessageArrived(message(user: ownUser), currentUser: ownUser, isAtBottom: false); + + expect(controller.unreadDividerGrowth.value, 0); + }); + + test('a message from a muted user does not count', () { + final controller = buildController(); + final muter = OwnUser( + id: 'ownid', + mutes: [ + Mute( + user: ownUser, + target: User(id: 'other'), + createdAt: DateTime.utc(2024), + updatedAt: DateTime.utc(2024), + ), + ], + ); + + controller.handleMessageArrived(message(), currentUser: muter, isAtBottom: false); + + expect(controller.unreadDividerGrowth.value, 0); + }); + + test('silent, shadowed and ephemeral messages do not count', () { + final controller = buildController() + ..handleMessageArrived(message(id: 'a', silent: true), currentUser: ownUser, isAtBottom: false) + ..handleMessageArrived(message(id: 'b', shadowed: true), currentUser: ownUser, isAtBottom: false) + ..handleMessageArrived( + message(id: 'c', type: MessageType.ephemeral), + currentUser: ownUser, + isAtBottom: false, + ); + + expect(controller.unreadDividerGrowth.value, 0); + }); + + test('a thread reply not also sent to the channel does not count', () { + final controller = buildController() + ..handleMessageArrived( + message(parentId: 'parent'), + currentUser: ownUser, + isAtBottom: false, + ); + + expect(controller.unreadDividerGrowth.value, 0); + }); + + test('a thread reply also sent to the channel counts', () { + final controller = buildController() + ..handleMessageArrived( + message(parentId: 'parent', showInChannel: true), + currentUser: ownUser, + isAtBottom: false, + ); + + expect(controller.unreadDividerGrowth.value, 1); + }); + + test('a message restricted to other users does not count', () { + final controller = buildController() + ..handleMessageArrived( + message(restrictedVisibility: const ['someone-else']), + currentUser: ownUser, + isAtBottom: false, + ); + + expect(controller.unreadDividerGrowth.value, 0); + }); + }); + + group('badge reset', () { + test('reaching the bottom clears the badge but keeps the divider growth', () { + markReadWhenAtTheBottom = false; + final controller = buildController()..handleMessageArrived(message(), currentUser: ownUser, isAtBottom: false); + + tick(controller, [2, 3], isAtBottom: true); + + expect(controller.scrollToBottomBadge.value, 0); + expect(controller.unreadDividerGrowth.value, 1); + }); + + test('a tick away from the bottom leaves the badge alone', () { + markReadWhenAtTheBottom = false; + final controller = buildController()..handleMessageArrived(message(), currentUser: ownUser, isAtBottom: false); + + tick(controller, [8, 9], isAtBottom: false); + + expect(controller.scrollToBottomBadge.value, 1); + }); + }); + + group('empty positions ticks', () { + test('an empty viewport does not count as laid out', () { + final controller = buildController(); + + tick(controller, [], isAtBottom: false); + + expect(controller.hasLaidOut.value, isFalse); + }); + + test('an empty viewport cannot satisfy the mark-unread divergence guard', () { + // Without the guard the empty tick would snapshot an empty index set, + // which the first real frame would then read as divergence and use to + // undo the manual mark-unread. + when(() => channelState.isMarkedAsUnread).thenReturn(true); + when(() => channelState.unreadCount).thenReturn(3); + when(() => channelState.currentUserRead).thenReturn(read(unreadMessages: 3)); + messages = [message(id: 'newest')]; + final controller = buildController()..attach(); + + tick(controller, [], isAtBottom: true); + tick(controller, [2, 3], isAtBottom: true); + + verifyNever(() => channel.markRead()); + }); + }); + + group('baseline capture', () { + test('publishes the frozen count before the anchor resolves', () { + when(() => channelState.currentUserRead).thenReturn(read(unreadMessages: 5, lastReadMessageId: 'm-5')); + firstUnreadMessage = null; // pagination hasn't reached the boundary yet + final controller = buildController()..attach(); + + expect(controller.unreadDivider.value.count, 5); + expect(controller.unreadDivider.value.anchorId, isNull); + expect(controller.needsAnchorResolution, isTrue); + }); + + test('resolveDividerAnchor fills in the anchor once the boundary loads', () { + when(() => channelState.currentUserRead).thenReturn(read(unreadMessages: 5, lastReadMessageId: 'm-5')); + final controller = buildController()..attach(); + + firstUnreadMessage = message(id: 'm-6'); + controller.resolveDividerAnchor(); + + expect(controller.unreadDivider.value, (count: 5, anchorId: 'm-6')); + expect(controller.needsAnchorResolution, isFalse); + }); + + test('the anchor is frozen once resolved', () { + when(() => channelState.currentUserRead).thenReturn(read(unreadMessages: 5, lastReadMessageId: 'm-5')); + firstUnreadMessage = message(id: 'm-6'); + final controller = buildController()..attach(); + + firstUnreadMessage = message(id: 'm-99'); + controller.resolveDividerAnchor(); + + expect(controller.unreadDivider.value.anchorId, 'm-6'); + }); + + test('a channel opened fully read publishes no divider', () { + when(() => channelState.currentUserRead).thenReturn(read(unreadMessages: 0, lastReadMessageId: 'm-9')); + final controller = buildController()..attach(); + + expect(controller.unreadDivider.value, (count: 0, anchorId: null)); + expect(controller.needsAnchorResolution, isFalse); + }); + + test('no baseline is captured in a thread', () { + parentMessage = message(id: 'parent'); + when(() => channelState.currentUserRead).thenReturn(read(unreadMessages: 5, lastReadMessageId: 'm-5')); + final controller = buildController()..attach(); + + expect(controller.unreadDivider.value, (count: 0, anchorId: null)); + }); + }); + + group('read state changes', () { + test('a fresh mark-unread restarts the divider session', () { + when(() => channelState.currentUserRead).thenReturn(read(unreadMessages: 2, lastReadMessageId: 'm-8')); + firstUnreadMessage = message(id: 'm-9'); + final controller = buildController() + ..attach() + ..handleMessageArrived(message(), currentUser: ownUser, isAtBottom: false); + expect(controller.unreadDividerGrowth.value, 1); + + // The user marks an older message unread: the flag flips on and the + // boundary moves backward. + when(() => channelState.isMarkedAsUnread).thenReturn(true); + when(() => channelState.currentUserRead).thenReturn(read(unreadMessages: 6, lastReadMessageId: 'm-4')); + firstUnreadMessage = message(id: 'm-5'); + controller.handleCurrentUserReadChanged(); + + expect(controller.unreadDivider.value, (count: 6, anchorId: 'm-5')); + expect(controller.unreadDividerGrowth.value, 0); + expect(controller.hasSeenFirstUnread.value, isFalse); + }); + + test('an emission that leaves the boundary alone does not restart the session', () { + when(() => channelState.isMarkedAsUnread).thenReturn(true); + when(() => channelState.currentUserRead).thenReturn(read(unreadMessages: 3, lastReadMessageId: 'm-4')); + firstUnreadMessage = message(id: 'm-5'); + final controller = buildController()..attach(); + // The session came from a manual mark-unread, so only scrolling *past* + // the anchor retires the pill: the anchor sits at item index 2, so a + // viewport showing only older items (higher indices) is past it. + messages = [message(id: 'm-5'), message(id: 'm-4')]; + tick(controller, [3], isAtBottom: false); + expect(controller.hasSeenFirstUnread.value, isTrue); + + // A new message arrives: unreadMessages grows, the boundary does not move. + when(() => channelState.currentUserRead).thenReturn(read(unreadMessages: 4, lastReadMessageId: 'm-4')); + controller.handleCurrentUserReadChanged(); + + expect(controller.hasSeenFirstUnread.value, isTrue, reason: 'the pill must not flicker back in'); + }); + + test('a second mark-unread further back re-anchors the divider', () { + when(() => channelState.isMarkedAsUnread).thenReturn(true); + when(() => channelState.currentUserRead).thenReturn(read(unreadMessages: 3, lastReadMessageId: 'm-6')); + firstUnreadMessage = message(id: 'm-7'); + final controller = buildController()..attach(); + + when(() => channelState.currentUserRead).thenReturn(read(unreadMessages: 7, lastReadMessageId: 'm-2')); + firstUnreadMessage = message(id: 'm-3'); + controller.handleCurrentUserReadChanged(); + + expect(controller.unreadDivider.value, (count: 7, anchorId: 'm-3')); + }); + }); + + group('mark-read gate', () { + test('marks read once the bottom is reached with something unread', () { + when(() => channelState.unreadCount).thenReturn(3); + when(() => channelState.currentUserRead).thenReturn(read(unreadMessages: 3)); + messages = [message(id: 'newest')]; + final controller = buildController()..attach(); + + tick(controller, [2], isAtBottom: true); + + verify(() => channel.markRead()).called(1); + }); + + test('does not mark read while the channel is not up to date', () { + when(() => channelState.isUpToDate).thenReturn(false); + when(() => channelState.unreadCount).thenReturn(3); + when(() => channelState.currentUserRead).thenReturn(read(unreadMessages: 3)); + final controller = buildController()..attach(); + + tick(controller, [2], isAtBottom: true); + + verifyNever(() => channel.markRead()); + }); + + test('does not mark read when there is nothing unread', () { + when(() => channelState.unreadCount).thenReturn(0); + final controller = buildController()..attach(); + + tick(controller, [2], isAtBottom: true); + + verifyNever(() => channel.markRead()); + }); + + test('does not mark read while the bottom has never been seen', () { + when(() => channelState.unreadCount).thenReturn(3); + when(() => channelState.currentUserRead).thenReturn(read(unreadMessages: 3)); + firstUnreadMessage = message(id: 'm-5'); + messages = [message(id: 'm-5')]; + final controller = buildController()..attach(); + + // Seeing the boundary opens condition 5, but the bottom is still away. + tick(controller, [2], isAtBottom: false); + + expect(controller.hasSeenFirstUnread.value, isTrue); + verifyNever(() => channel.markRead()); + }); + + test('does not mark read while the unread boundary has not been seen', () { + when(() => channelState.unreadCount).thenReturn(3); + when(() => channelState.currentUserRead).thenReturn(read(unreadMessages: 3, lastReadMessageId: 'm-4')); + firstUnreadMessage = message(id: 'm-5'); + messages = [message(id: 'newest'), message(id: 'm-5')]; + final controller = buildController()..attach(); + + // At the bottom, but the anchor (item index 3) is out of view. + tick(controller, [2], isAtBottom: true); + + verifyNever(() => channel.markRead()); + }); + + test('marks read on a never-opened channel that has no boundary to reach', () { + when(() => channelState.unreadCount).thenReturn(3); + when(() => channelState.currentUserRead).thenReturn(read(unreadMessages: 3)); + final controller = buildController()..attach(); + + tick(controller, [2], isAtBottom: true); + + verify(() => channel.markRead()).called(1); + }); + + test('does nothing while markReadWhenAtTheBottom is off', () { + markReadWhenAtTheBottom = false; + when(() => channelState.unreadCount).thenReturn(3); + when(() => channelState.currentUserRead).thenReturn(read(unreadMessages: 3)); + final controller = buildController()..attach(); + + tick(controller, [2], isAtBottom: true); + + verifyNever(() => channel.markRead()); + }); + + test('a repeated tick against unchanged state does not retry the attempt', () { + when(() => channelState.unreadCount).thenReturn(3); + when(() => channelState.currentUserRead).thenReturn(read(unreadMessages: 3)); + messages = [message(id: 'newest')]; + final controller = buildController()..attach(); + + tick(controller, [2], isAtBottom: true); + tick(controller, [2], isAtBottom: true); + tick(controller, [2], isAtBottom: true); + + verify(() => channel.markRead()).called(1); + }); + + test('a new newest message earns a fresh attempt', () async { + when(() => channelState.unreadCount).thenReturn(3); + when(() => channelState.currentUserRead).thenReturn(read(unreadMessages: 3)); + messages = [message(id: 'newest')]; + final controller = buildController()..attach(); + tick(controller, [2], isAtBottom: true); + + // The debounce is leading-edge, so let its window lapse before the + // second attempt, which is otherwise swallowed by the debouncer + // rather than by the dedupe key under test. + await Future.delayed(const Duration(milliseconds: 1100)); + messages = [message(id: 'even-newer'), message(id: 'newest')]; + tick(controller, [2], isAtBottom: true); + + verify(() => channel.markRead()).called(2); + }); + + group('with an active manual mark-unread', () { + setUp(() { + when(() => channelState.isMarkedAsUnread).thenReturn(true); + when(() => channelState.unreadCount).thenReturn(3); + when(() => channelState.currentUserRead).thenReturn(read(unreadMessages: 3)); + messages = [message(id: 'newest')]; + }); + + test('does not mark read while the viewport has not moved', () { + final controller = buildController()..attach(); + + tick(controller, [2, 3], isAtBottom: true); + tick(controller, [2, 3], isAtBottom: true); + + verifyNever(() => channel.markRead()); + }); + + test('marks read once the viewport genuinely differs', () { + final controller = buildController()..attach(); + + tick(controller, [2, 3], isAtBottom: false); + tick(controller, [4, 5], isAtBottom: true); + + verify(() => channel.markRead()).called(1); + }); + + test('divergence latches, so returning to the same rest position still marks read', () { + final controller = buildController()..attach(); + + tick(controller, [2, 3], isAtBottom: true); + tick(controller, [6, 7], isAtBottom: false); + tick(controller, [2, 3], isAtBottom: true); + + verify(() => channel.markRead()).called(1); + }); + }); + + group('in a thread', () { + test('marks the thread read once the parent has replies', () { + parentMessage = Message(id: 'parent', text: 'p', replyCount: 2); + final controller = buildController()..attach(); + + tick(controller, [2], isAtBottom: true); + + verify(() => channel.markThreadRead('parent')).called(1); + }); + + test('does not mark a reply-less parent read', () { + parentMessage = Message(id: 'parent', text: 'p', replyCount: 0); + final controller = buildController()..attach(); + + tick(controller, [2], isAtBottom: true); + + verifyNever(() => channel.markThreadRead(any())); + }); + }); + }); + + group('pill taps', () { + test('jump scrolls to the resolved anchor and retires the pill', () async { + when(() => channelState.currentUserRead).thenReturn(read(unreadMessages: 4, lastReadMessageId: 'm-4')); + firstUnreadMessage = message(id: 'm-5'); + final controller = buildController()..attach(); + + await controller.onPillJumpTapped(); + + expect(scrollRequests, ['m-5']); + expect(controller.hasSeenFirstUnread.value, isTrue); + }); + + test('jump falls back to the baseline boundary when the anchor is unresolved', () async { + when(() => channelState.currentUserRead).thenReturn(read(unreadMessages: 4, lastReadMessageId: 'm-4')); + firstUnreadMessage = null; + final controller = buildController()..attach(); + + await controller.onPillJumpTapped(); + + expect(scrollRequests, ['m-4']); + }); + + test('jump heads for the oldest loaded message when there is no boundary at all', () async { + when(() => channelState.currentUserRead).thenReturn(read(unreadMessages: 4)); + messages = [message(id: 'newest'), message(id: 'oldest')]; + final controller = buildController()..attach(); + + await controller.onPillJumpTapped(); + + expect(scrollRequests, ['oldest']); + expect( + controller.hasSeenFirstUnread.value, + isFalse, + reason: 'the real boundary is further back than this lands', + ); + }); + + test('a jump that never landed leaves the pill up', () async { + scrollLands = false; + when(() => channelState.currentUserRead).thenReturn(read(unreadMessages: 4, lastReadMessageId: 'm-4')); + firstUnreadMessage = message(id: 'm-5'); + final controller = buildController()..attach(); + + await controller.onPillJumpTapped(); + + expect(controller.hasSeenFirstUnread.value, isFalse); + }); + + test('a jump result arriving after a channel change is dropped', () async { + when(() => channelState.currentUserRead).thenReturn(read(unreadMessages: 4, lastReadMessageId: 'm-4')); + firstUnreadMessage = message(id: 'm-5'); + final controller = buildController()..attach(); + + final jump = controller.onPillJumpTapped(); + attachToken = 'channel-2'; + await jump; + + expect(controller.hasSeenFirstUnread.value, isFalse); + }); + + test('dismiss retires the pill and marks the channel read immediately', () async { + final controller = buildController(); + + await controller.onPillDismissTapped(); + + expect(controller.hasSeenFirstUnread.value, isTrue); + verify(() => channel.markRead()).called(1); + }); + + test('dismiss in a thread marks the thread read', () async { + parentMessage = message(id: 'parent'); + final controller = buildController(); + + await controller.onPillDismissTapped(); + + verify(() => channel.markThreadRead('parent')).called(1); + }); + }); +} diff --git a/packages/stream_chat_flutter/test/src/message_list_view/unread_divider_test.dart b/packages/stream_chat_flutter/test/src/message_list_view/unread_divider_test.dart index b540dd42b2..095847065d 100644 --- a/packages/stream_chat_flutter/test/src/message_list_view/unread_divider_test.dart +++ b/packages/stream_chat_flutter/test/src/message_list_view/unread_divider_test.dart @@ -5,8 +5,8 @@ // pre-existing unread boundary captured when the channel opens. The // anchor is frozen — it stays on screen for the whole session regardless // of scrolling or reads — but its displayed count keeps counting up as -// further messages arrive out of view during the session, mirroring -// WhatsApp, rather than staying frozen at the open-time count. +// further messages arrive out of view during the session, rather than +// staying frozen at the open-time count. // - The pill shows the count of unread messages captured when the channel // was opened — this one *does* stay frozen — and is gated on that // boundary being above the viewport. From 2711ddb964cdad869be333b93b09314e059d805b Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Fri, 21 Aug 2026 10:31:44 +0200 Subject: [PATCH 11/14] add code docs --- .../message_list_view/message_list_unread_controller.dart | 7 +++++++ .../stream_message_list_view_configuration.dart | 8 ++++++++ 2 files changed, 15 insertions(+) diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_unread_controller.dart b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_unread_controller.dart index 8aa6bc161d..683c2100fe 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_unread_controller.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_unread_controller.dart @@ -534,6 +534,13 @@ class MessageListUnreadController { // isn't about how the channel is configured but about the user opting out // of unread tracking entirely, and honouring it here is what keeps these // indicators from counting up while the channel itself reports zero. + // + // Silent messages are excluded even though — unlike shadowed ones — they + // are rendered in the list: not bumping the unread count is the definition + // of the flag rather than a side effect of hiding the message. It is also + // what [MessageRules.canCountAsUnread] and the channel's own unread count + // already do, so counting them here would make the divider disagree with + // `channel.state.unreadCount` in the same view. bool _countsTowardsUnreadIndicators(Message message, OwnUser? currentUser) { if (currentUser == null) return false; if (!currentUser.isReadReceiptsEnabled) return false; diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/stream_message_list_view_configuration.dart b/packages/stream_chat_flutter/lib/src/message_list_view/stream_message_list_view_configuration.dart index 2cc256e646..a6eb9e24b9 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view/stream_message_list_view_configuration.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view/stream_message_list_view_configuration.dart @@ -62,6 +62,14 @@ class StreamMessageListViewConfiguration { /// Whether to show the jump-to-unread indicator when there are unread /// messages. /// + /// The indicator's dismiss button is the user-facing way to clear an unread + /// boundary the user will never scroll back to. Turning this off while + /// [StreamChannel.openAtFirstUnread] is also false leaves a channel that + /// neither opens at the boundary nor offers a way to dismiss it, so with + /// [markReadWhenAtTheBottom] on it will not be marked read until the user + /// scrolls back to that boundary themselves. Call + /// [Channel.markRead] directly if you disable both. + /// /// Defaults to true. final bool showUnreadIndicator; From 241584395f083d3f914ce337d5aa17f30abe354b Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Fri, 21 Aug 2026 11:07:25 +0200 Subject: [PATCH 12/14] add localization test --- .../test/override_test.dart | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/packages/stream_chat_localizations/test/override_test.dart b/packages/stream_chat_localizations/test/override_test.dart index 7714fa5f5a..ee8a95e8d3 100644 --- a/packages/stream_chat_localizations/test/override_test.dart +++ b/packages/stream_chat_localizations/test/override_test.dart @@ -37,6 +37,22 @@ class FooStreamChatLocalizationsDelegate extends LocalizationsDelegate false; } +/// A subclass written before [StreamChatLocalizations.unreadMessagesSeparatorLabel] +/// existed: it overrides only the deprecated count-less method. +/// +/// Every other member is left to `noSuchMethod` forwarding, so the class stays +/// focused on the one inherited behaviour under test. +class LegacyStreamChatLocalizations extends GlobalStreamChatLocalizations { + const LegacyStreamChatLocalizations() : super(localeName: 'en'); + + @override + // ignore: deprecated_member_use + String unreadMessagesSeparatorText() => 'custom new messages'; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + Widget buildFrame({ Locale? locale, Iterable delegates = GlobalStreamChatLocalizations.delegates, @@ -273,4 +289,23 @@ void main() { expect(find.text('foo'), findsOneWidget); }, ); + + test( + 'a subclass predating unreadMessagesSeparatorLabel keeps its custom text', + () { + const localizations = LegacyStreamChatLocalizations(); + + // The inherited fallback forwards to the deprecated method, so a + // subclass that only overrides the old one keeps rendering its own + // copy instead of reverting to the built-in count-aware string. + expect( + localizations.unreadMessagesSeparatorLabel(count: 1), + 'custom new messages', + ); + expect( + localizations.unreadMessagesSeparatorLabel(count: 5), + 'custom new messages', + ); + }, + ); } From e1a4e3a37bc45135fa4433cdf54fd285426768ee Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Fri, 21 Aug 2026 13:28:29 +0200 Subject: [PATCH 13/14] make mark read retryable --- .../message_list_unread_controller.dart | 24 +++++++++- packages/stream_chat_flutter/pubspec.yaml | 1 + .../message_list_unread_controller_test.dart | 45 ++++++++++++++----- .../test/stream_channel_test.dart | 34 ++++++++++++++ 4 files changed, 92 insertions(+), 12 deletions(-) diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_unread_controller.dart b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_unread_controller.dart index 9bcf95d538..baab693fb4 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_unread_controller.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_unread_controller.dart @@ -220,18 +220,38 @@ class MessageListUnreadController { // Debounced channel mark-read. late final _debouncedMarkRead = debounce( - ([String? id]) => _channel()?.markRead(messageId: id), + ([String? id]) => _retryableMarkRead(_channel()?.markRead(messageId: id)), const Duration(seconds: 1), leading: true, ); // Debounced thread mark-read. late final _debouncedMarkThreadRead = debounce( - (String parentId) => _channel()?.markThreadRead(parentId), + (String parentId) => _retryableMarkRead(_channel()?.markThreadRead(parentId)), const Duration(seconds: 1), leading: true, ); + // Clears [_lastMarkReadAttempt] when a mark-read request fails, so the same + // state can be attempted again. + // + // The gate records the attempt key before the request is issued, and a + // failed request leaves every input that key is built from unchanged — the + // count only drops once the server's read event arrives. Without this, one + // transient failure (offline, a 5xx) would block every later attempt for + // that state until a new message arrived or the channel was reopened. + // + // The error itself is swallowed rather than rethrown: the request is a + // best-effort background action with no user-facing surface, and the future + // is discarded by the debouncer, so rethrowing would only raise an + // unhandled async error. + Future? _retryableMarkRead(Future? request) { + return request?.onError((_, __) { + _lastMarkReadAttempt = null; + return EmptyResponse(); + }); + } + /// Resets every piece of unread state for a newly attached channel. /// /// Must be called after the new channel is reachable through the accessors diff --git a/packages/stream_chat_flutter/pubspec.yaml b/packages/stream_chat_flutter/pubspec.yaml index bba0f2a0f3..84e68215ca 100644 --- a/packages/stream_chat_flutter/pubspec.yaml +++ b/packages/stream_chat_flutter/pubspec.yaml @@ -80,6 +80,7 @@ dev_dependencies: alchemist: ^0.14.0 build_runner: ^2.15.0 connectivity_plus_platform_interface: ^2.1.0 + fake_async: ^1.3.3 faker_dart: ^0.2.3 flutter_test: sdk: flutter diff --git a/packages/stream_chat_flutter/test/src/message_list_view/message_list_unread_controller_test.dart b/packages/stream_chat_flutter/test/src/message_list_view/message_list_unread_controller_test.dart index 57468260ed..ec5ea5e9ed 100644 --- a/packages/stream_chat_flutter/test/src/message_list_view/message_list_unread_controller_test.dart +++ b/packages/stream_chat_flutter/test/src/message_list_view/message_list_unread_controller_test.dart @@ -7,6 +7,7 @@ // individual condition of the mark-read gate, the attempt-dedupe key, the // mark-unread viewport divergence latch, and the pill's jump fallbacks. +import 'package:fake_async/fake_async.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:stream_chat_flutter/scrollable_positioned_list/scrollable_positioned_list.dart'; @@ -494,21 +495,45 @@ void main() { verify(() => channel.markRead()).called(1); }); - test('a new newest message earns a fresh attempt', () async { + test('a new newest message earns a fresh attempt', () { when(() => channelState.unreadCount).thenReturn(3); when(() => channelState.currentUserRead).thenReturn(read(unreadMessages: 3)); messages = [message(id: 'newest')]; - final controller = buildController()..attach(); - tick(controller, [2], isAtBottom: true); - // The debounce is leading-edge, so let its window lapse before the - // second attempt, which is otherwise swallowed by the debouncer - // rather than by the dedupe key under test. - await Future.delayed(const Duration(milliseconds: 1100)); - messages = [message(id: 'even-newer'), message(id: 'newest')]; - tick(controller, [2], isAtBottom: true); + fakeAsync((async) { + final controller = buildController()..attach(); + tick(controller, [2], isAtBottom: true); + + // The debounce is leading-edge, so let its window lapse before the + // second attempt, which is otherwise swallowed by the debouncer + // rather than by the dedupe key under test. + async.elapse(const Duration(seconds: 1)); + messages = [message(id: 'even-newer'), message(id: 'newest')]; + tick(controller, [2], isAtBottom: true); + + verify(() => channel.markRead()).called(2); + }); + }); - verify(() => channel.markRead()).called(2); + test('a failed mark-read can be retried for the same state', () { + when(() => channelState.unreadCount).thenReturn(3); + when(() => channelState.currentUserRead).thenReturn(read(unreadMessages: 3)); + when(() => channel.markRead()).thenAnswer((_) => Future.error(Exception('offline'))); + messages = [message(id: 'newest')]; + + fakeAsync((async) { + final controller = buildController()..attach(); + tick(controller, [2], isAtBottom: true); + + // Nothing about the channel changes when the request fails — the + // count only drops once the server's read event arrives — so without + // the attempt key being cleared this second tick would be deduped + // away and the channel would stay unread. + async.elapse(const Duration(seconds: 1)); + tick(controller, [2], isAtBottom: true); + + verify(() => channel.markRead()).called(2); + }); }); group('with an active manual mark-unread', () { diff --git a/packages/stream_chat_flutter_core/test/stream_channel_test.dart b/packages/stream_chat_flutter_core/test/stream_channel_test.dart index cb5d4b3686..0699c2b045 100644 --- a/packages/stream_chat_flutter_core/test/stream_channel_test.dart +++ b/packages/stream_chat_flutter_core/test/stream_channel_test.dart @@ -1120,6 +1120,40 @@ void main() { }, ); + testWidgets( + 'queries the latest page when openAtFirstUnread is false and the ' + 'channel is stale', + (tester) async { + when(() => mockChannel.state.isUpToDate).thenReturn(false); + final read = Read( + user: User(id: 'testUserId'), + lastRead: DateTime.now(), + unreadMessages: 100, + lastReadMessageId: 'last-read-msg', + ); + when(() => mockChannel.state.unreadCount).thenReturn(100); + when(() => mockChannel.state.currentUserRead).thenReturn(read); + + await _pumpStreamChannel(tester, mockChannel, openAtFirstUnread: false); + + // With `openAtFirstUnread: true` this read state would anchor the + // query on `last-read-msg`. Opting out has to skip that and fall + // through to the catch-all "load latest" path, which — unlike the + // up-to-date case above — a stale channel actually reaches. + final captured = + verify( + () => mockChannel.query( + preferOffline: any(named: 'preferOffline'), + messagesPagination: captureAny(named: 'messagesPagination'), + ), + ).captured.single + as PaginationParams; + + expect(captured.idAround, isNull); + expect(captured.createdAtAround, isNull); + }, + ); + testWidgets( 'openAtFirstUnread: false still honours an explicit initialMessageId', (tester) async { From 00fff826c30b3a10704bc34552a641ffbbc841ff Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Fri, 21 Aug 2026 15:25:27 +0200 Subject: [PATCH 14/14] improve internal documentation --- .../message_list_unread_controller.dart | 96 ++++++++++++++++--- .../message_list_view/message_list_view.dart | 6 +- 2 files changed, 84 insertions(+), 18 deletions(-) diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_unread_controller.dart b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_unread_controller.dart index baab693fb4..c72c2f2f99 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_unread_controller.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_unread_controller.dart @@ -1,3 +1,69 @@ +// Unread state for the message list: what each indicator means, and how the +// three of them behave together. +// +// There are three separate surfaces, and the most common source of confusion +// is that they answer three different questions: +// +// - The **unread messages divider** ("N unread messages") — an inline +// separator marking where you had read up to when you opened the channel. +// Its anchor is captured once, at open, and never moves: it stays put +// across scrolling and across an auto mark-read, so the place you left off +// remains findable for the whole visit. Its *count* does climb as further +// qualifying messages arrive during the session. +// - The **jump-to-unread pill** (floating, at the top) — a way to get *to* +// that divider. It shows the count frozen at open and deliberately ignores +// the divider's growth, so the number does not move under the user's +// finger. Once the boundary has been reached it is gone for the rest of the +// session. +// - The **scroll-to-bottom badge** — purely "what did I miss while looking +// away". It counts only messages that arrive while scrolled up, and resets +// to 0 on reaching the bottom. It is never seeded from the channel's +// unread count, so opening a channel with 50 unread shows no badge. +// +// Notably there is *no* separate "new messages" banner for messages that +// arrive while the channel is open: an arrival grows the existing divider's +// count, and that is all. +// +// How that plays out, case by case: +// +// - **Opened with unread messages.** The divider is anchored at the first +// unread message and the pill appears with the open-time count. By +// default ([StreamChannel.openAtFirstUnread]) the list also opens +// positioned at that boundary, so the pill is usually retired on the first +// laid-out frame. With `openAtFirstUnread: false` the list opens at the +// newest message instead and the pill stays up until the user scrolls back +// to the boundary, taps it, or dismisses it. +// - **Opened fully read.** No divider and no pill — there is no boundary to +// anchor to, and later arrivals do not create one. Only the badge reacts, +// and only while scrolled away from the bottom. +// - **Messages arriving during the session.** The divider's count climbs +// whether or not the user is at the bottom; the badge climbs only when +// they are scrolled away. Neither can make the pill reappear. +// - **Manually marked unread.** Treated as a fresh start: the baseline is +// recaptured, the divider re-anchors, and the pill comes back. Because the +// anchor is the message the user just acted on — already on screen — +// only scrolling *past* it retires the pill, and auto mark-read stays +// blocked until the viewport genuinely moves, so the action is not undone +// on the next layout tick. +// - **Never opened by this user.** The channel reports unread messages but +// has no read boundary at all, so the anchor cannot resolve until top +// pagination reaches the start of the channel. The pill still shows, and +// tapping it jumps as far back as is currently loaded (pulling in the next +// page) rather than doing nothing; it stays up, since the real boundary +// has not been reached. Auto mark-read is exempt from waiting for a +// boundary here — see [_maybeMarkMessagesAsRead] condition 5 — as are +// channels tracking unread counts locally. +// - **Threads.** None of the three surfaces apply; only the thread's own +// mark-read does. +// +// What counts towards the divider and badge is narrower than "a message +// arrived": silent, shadowed, ephemeral, thread-only, restricted, own and +// muted-sender messages are all skipped, and nothing counts while the user +// has read receipts disabled. See [_countsTowardsUnreadIndicators]. +// +// Auto mark-read has its own five-condition gate, documented on +// [_maybeMarkMessagesAsRead]. + import 'dart:math'; import 'package:collection/collection.dart'; @@ -73,7 +139,7 @@ class MessageListUnreadController { bool _disposed = false; - // --- Divider A: pre-existing unread, frozen at channel open --- + // --- The unread divider: pre-existing unread, frozen at channel open --- // // [_unreadBaseline] is the current user's [Read] captured once when the // channel is attached (or on the first `currentUserReadStream` emission if @@ -84,7 +150,7 @@ class MessageListUnreadController { Read? _unreadBaseline; bool _unreadBaselineCaptured = false; - // Resolved anchor for divider A. The anchor (and `count`, the frozen + // Resolved anchor for the unread divider. The anchor (and `count`, the frozen // baseline used by the pill) is frozen once non-null: recomputation is // skipped as soon as `anchorId` is set. May take a few rebuilds to resolve // if top pagination hasn't finished loading the boundary yet. @@ -98,7 +164,7 @@ class MessageListUnreadController { final ValueNotifier _unreadDividerGrowth = ValueNotifier(0); // Sticky: becomes true once the user has seen (rendered) or scrolled past - // divider A's anchor. Drives the pill's permanent dismissal and (see + // the unread divider's anchor. Drives the pill's permanent dismissal and (see // [_maybeMarkMessagesAsRead]) gates auto mark-read. final ValueNotifier _hasSeenFirstUnread = ValueNotifier(false); @@ -189,7 +255,7 @@ class MessageListUnreadController { return (lastRead: read.lastRead, lastReadMessageId: read.lastReadMessageId); } - // Whether divider A's current session came from an explicit mark-unread + // Whether the unread divider's current session came from an explicit mark-unread // rather than from pre-existing unread at channel open. The anchor of a // manual mark-unread is the message the user was looking at when they // marked it, so it's already on screen — see @@ -289,8 +355,8 @@ class MessageListUnreadController { } // Captures [_unreadBaseline] the first time the current user's read state - // becomes available, then attempts to resolve divider A's anchor against - // it. No-ops in a thread, where divider A doesn't apply. + // becomes available, then attempts to resolve the unread divider's anchor + // against it. No-ops in a thread, where the divider doesn't apply. void _captureUnreadBaselineIfNeeded() { if (_unreadBaselineCaptured || _isThreadConversation) return; @@ -310,7 +376,7 @@ class MessageListUnreadController { resolveDividerAnchor(); } - /// Resolves divider A's anchor against the frozen baseline. A no-op once + /// Resolves the unread divider's anchor against the frozen baseline. A no-op once /// resolved, and while top pagination hasn't loaded the boundary yet. void resolveDividerAnchor() { if (_isThreadConversation || _unreadDivider.value.anchorId != null) return; @@ -326,7 +392,7 @@ class MessageListUnreadController { /// Reacts to a `currentUserReadStream` emission. An explicit mark-unread /// moves the read boundary backward — treat it as a new session start for - /// divider A/the pill. + /// the unread divider and the pill. /// /// The reset is deliberately gated on a *new* mark-unread — the flag /// turning on, or the read boundary moving again while it's already on — @@ -341,7 +407,7 @@ class MessageListUnreadController { /// `isMarkedAsUnread` is cleared solely by a mark-read (see /// `ChannelClientState.markReadLocally`), and both the baseline capture and /// [resolveDividerAnchor] freeze once resolved — so this reset is the only - /// thing that can move divider A once a mark-unread session is under way. + /// thing that can move the divider once a mark-unread session is under way. void handleCurrentUserReadChanged() { if (_isThreadConversation) return; @@ -481,7 +547,7 @@ class MessageListUnreadController { // [StreamChannelState.loadChannelAtMessage] when the anchor isn't in the // currently loaded window — after which the real anchor resolves // naturally via the list's retry of [resolveDividerAnchor], rendering - // divider A too. That can await pagination and a frame, and this + // the divider too. That can await pagination and a frame, and this // controller survives a channel change — so remember which channel the // tap was for and drop the result if it isn't the current one any more. final tappedFor = _attachToken(); @@ -527,7 +593,7 @@ class MessageListUnreadController { } // Whether a freshly-arrived [message] should bump the scroll-to-bottom - // badge and divider A's growing count. + // badge and the unread divider's growing count. // // This is the message- and sender-level half of // [MessageRules.canCountAsUnread], which is what keeps silent, shadowed, @@ -607,9 +673,9 @@ class MessageListUnreadController { } } - // Marks divider A's anchor as seen once it renders on screen, or once the - // user scrolls past it without it ever rendering (a fast fling can skip - // intermediate frames). Sticky: never reverts once true, and reset only + // Marks the unread divider's anchor as seen once it renders on screen, or + // once the user scrolls past it without it ever rendering (a fast fling can + // skip intermediate frames). Sticky: never reverts once true, and reset only // when the baseline is recaptured (channel change, or an explicit // mark-unread — see [handleCurrentUserReadChanged]). // @@ -674,7 +740,7 @@ class MessageListUnreadController { // anchor being immediately "visible" again (it's usually the very // message just marked, with nothing yet scrolled) would undo the // user's action instantly. - // 5. Divider A's anchor has actually been seen or scrolled past + // 5. The unread divider's anchor has actually been seen or scrolled past // (`hasSeenFirstUnreadMessage`) — trivially satisfied when there is // no boundary to see in the first place: the channel opened fully // read, the user has never opened it at all (no `lastReadMessageId`, diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart index 89cbb1ed0b..793b578ab6 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart @@ -990,9 +990,9 @@ class _StreamMessageListViewState extends State { } // Wraps an already-built [separator] with the unread-messages line if - // [message] happens to be divider A's anchor. Defined as a method (rather - // than a closure inside [separatorBuilder]) so a fresh inner closure isn't - // allocated for every visible separator on every rebuild. + // [message] happens to be the unread divider's anchor. Defined as a method + // (rather than a closure inside [separatorBuilder]) so a fresh inner closure + // isn't allocated for every visible separator on every rebuild. Widget _maybeBuildWithUnreadMessagesSeparator({ required Message message, required Widget separator,