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/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index 02246f10b8..fdeeb113d0 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -11,6 +11,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. - Exported `FilterOperator` alongside `Filter`. ⚠️ Deprecated diff --git a/packages/stream_chat/lib/src/client/channel.dart b/packages/stream_chat/lib/src/client/channel.dart index 5cfae938aa..2f9486e3fd 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,17 @@ 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. + 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 +3720,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 +3760,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. diff --git a/packages/stream_chat/test/src/client/channel_test.dart b/packages/stream_chat/test/src/client/channel_test.dart index 86b6f2677f..a5ea668af0 100644 --- a/packages/stream_chat/test/src/client/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel_test.dart @@ -6812,6 +6812,119 @@ 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 reset unread count on notification mark read event', () async { @@ -10973,6 +11086,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 = [ diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index 57a682cfe6..687b2dd6d0 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -40,6 +40,18 @@ - 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). - Exported `StreamEphemeralMessage`, the row `StreamMessageListView` builds for ephemeral messages, matching its already-exported `StreamSystemMessage` and `StreamModeratedMessage` siblings. - 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 `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 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 + +- `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, 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 @@ -47,6 +59,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 @@ -58,6 +71,12 @@ - Fixed the "Message deleted" bubble overflowing its maximum width when the localized label is long; the label now wraps instead. - Fixed the thread scroll-to-bottom button keying off the parent channel's up-to-date state instead of the thread's own scroll position, so it no longer appears while already at the newest reply. - 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, 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. +- 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 the thread-replies footer under a message in the channel being hardcoded English and reading "1 replies" for a single reply; it now uses `threadReplyCountText`, which is localized and correctly singularized. - Fixed a channel-list row briefly previewing another channel's last message after the list reorders. The preserved last-known message is now dropped when a row is rebound to a different channel, instead of being used as a fallback while the new channel is still loading. - Fixed the channel list still showing a timestamp next to "No messages yet" after a channel is truncated. `ChannelLastMessageDate` now reads the date off the message the preview actually shows instead of `Channel.lastMessageAt`, which cannot be cleared once a truncation removes every message. diff --git a/packages/stream_chat_flutter/lib/src/localization/translations.dart b/packages/stream_chat_flutter/lib/src/localization/translations.dart index d495e6564f..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'; @@ -100,8 +101,27 @@ 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". + /// + /// 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(); + } + /// The label for "connected" in [StreamConnectionStatusBuilder] String get connectedLabel; @@ -1290,6 +1310,16 @@ Attachment limit exceeded: it's not possible to add more than $limit attachments @override String unreadMessagesSeparatorText() => 'New messages'; + @override + String unreadMessagesSeparatorLabel({required int count}) { + return Intl.plural( + count, + one: '$count unread message', + other: '$count unread messages', + locale: 'en', + ); + } + @override String get enableFileAccessMessage => 'Please enable access to files' 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..c72c2f2f99 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_unread_controller.dart @@ -0,0 +1,830 @@ +// 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'; +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 this._channel, + required this._getFirstUnreadMessage, + required this._parentMessage, + required this._messages, + required this._itemPositions, + required this._markReadWhenAtTheBottom, + required this._scrollToMessage, + required this._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; + + // --- 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 + // 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 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. + 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 + // the unread divider'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 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 + // [_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]) => _retryableMarkRead(_channel()?.markRead(messageId: id)), + const Duration(seconds: 1), + leading: true, + ); + + // Debounced thread mark-read. + late final _debouncedMarkThreadRead = debounce( + (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 + /// 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 the unread divider's anchor + // against it. No-ops in a thread, where the divider 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 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; + + 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 + /// 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 — + /// 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 the divider 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 + // 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(); + 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 the unread divider'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. + // + // 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; + + 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 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]). + // + // 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. 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`, + // 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 e0ca7f074e..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 @@ -8,11 +8,11 @@ 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'; 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/misc/empty_widget.dart'; import 'package:stream_chat_flutter/src/utils/network_error_text.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; @@ -312,16 +312,19 @@ 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)); - - // 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, + // 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: () => _config.markReadWhenAtTheBottom, + scrollToMessage: (id) => _scrollToMessage(messageId: id, highlight: false), + attachToken: () => streamChannel, ); bool get _upToDate => streamChannel!.channel.state!.isUpToDate; @@ -394,10 +397,14 @@ class _StreamMessageListViewState extends State { if (newStreamChannel != streamChannel) { streamChannel = newStreamChannel; - debouncedMarkRead.cancel(); - debouncedMarkThreadRead.cancel(); + final newChannelState = newStreamChannel.channel.state; - _unreadState.value = _readUnreadSnapshot(); + _showScrollToBottom.value = false; + // 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 = _config.highlightInitialMessage; final highlightMessageId = switch ((highlightInitialMessage, _isThreadConversation)) { @@ -413,7 +420,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, @@ -421,13 +428,23 @@ class _StreamMessageListViewState extends State { _messageNewListener?.cancel(); _messageNewListener = newMessageStream?.listen((message) { + final currentUser = streamChannel?.channel.client.state.currentUser; + final isAtBottom = !_showScrollToBottom.value; + + // 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. + _unreadController.handleMessageArrived( + message, + currentUser: currentUser, + isAtBottom: isAtBottom, + ); + // 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; - final details = StreamAutoScrollDetails( message: message, currentUser: currentUser, @@ -453,7 +470,7 @@ class _StreamMessageListViewState extends State { _userReadListener?.cancel(); _userReadListener = state?.currentUserReadStream.listen((_) { - _unreadState.value = _readUnreadSnapshot(); + _unreadController.handleCurrentUserReadChanged(); }); } } @@ -468,16 +485,15 @@ class _StreamMessageListViewState extends State { @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; _userReadListener?.cancel(); _userReadListener = null; _itemPositionListener.itemPositions.removeListener(_handleItemPositionsChanged); - debouncedMarkRead.cancel(); - debouncedMarkThreadRead.cancel(); - _unreadState.dispose(); + _unreadController.dispose(); + _showScrollToBottom.dispose(); _highlightState.dispose(); super.dispose(); } @@ -494,7 +510,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, @@ -508,7 +527,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`, @@ -516,17 +535,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 @@ -538,6 +557,7 @@ class _StreamMessageListViewState extends State { ); if (highlight && mounted) _highlightMessage(messageId); + return true; } // Wraps [child] in the highlight pulse if [message] is the currently @@ -640,6 +660,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 (_unreadController.needsAnchorResolution) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _unreadController.resolveDividerAnchor(); + }); + } + final itemCount = messages.length + // total messages 2 + // top + bottom loading indicator @@ -875,9 +906,41 @@ class _StreamMessageListViewState extends State { if (_config.showUnreadIndicator && !_isThreadConversation) Positioned( top: math.max(_scaffoldInsets.top, context.streamSpacing.sm), - child: UnreadIndicatorButton( - onJumpTap: scrollToUnreadDefaultTapAction, - onDismissTap: _markMessagesAsRead, + child: ValueListenableBuilder( + 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 + // 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: _unreadController.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: _unreadController.hasSeenFirstUnread, + builder: (context, seen, __) { + if (seen) return const Empty(); + return UnreadIndicatorButton( + unreadCount: unread.count, + onJumpTap: (_) => _unreadController.onPillJumpTapped(), + onDismissTap: _unreadController.onPillDismissTapped, + ); + }, + ); + }, + ); + }, ), ), ], @@ -920,33 +983,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 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, }) { 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: _unreadController.unreadDivider, + builder: (context, unread, _) { + if (unread.anchorId != message.id) return separator; + return ValueListenableBuilder( + valueListenable: _unreadController.unreadDividerGrowth, + builder: (context, growth, __) => Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [separator, _buildUnreadMessagesSeparator(unread.count + growth)], + ), ); }, ); @@ -972,55 +1034,6 @@ 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; - - if (_scrollController case final controller? when controller.isAttached) { - return controller.scrollTo( - index: math.max(firstUnreadMessageIndex + 2, 0), - alignment: 0.5, // center the message in the viewport - ); - } - } - - 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 @@ -1122,15 +1135,14 @@ class _StreamMessageListViewState extends State { } Widget _buildScrollToBottom() { - return ValueListenableBuilder( - valueListenable: _unreadState, - builder: (_, state, __) { - final unreadCount = state.count; + return ValueListenableBuilder( + valueListenable: _unreadController.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, @@ -1141,12 +1153,12 @@ class _StreamMessageListViewState extends State { true => Icon(context.streamIcons.arrowDown), false => Icon(context.streamIcons.arrowUp), }, - onPressed: () => scrollToBottomDefaultTapAction(unreadCount), + onPressed: () => scrollToBottomDefaultTapAction(badgeCount), ); if (showUnreadCount && _config.showUnreadCountOnScrollToBottom) { button = StreamBadgeNotification( - label: '${unreadCount > 99 ? '99+' : unreadCount}', + label: '${badgeCount > 99 ? '99+' : badgeCount}', child: button, ); } @@ -1255,6 +1267,8 @@ class _StreamMessageListViewState extends State { } void _handleItemPositionsChanged() { + if (!mounted) return; + final itemPositions = _itemPositionListener.itemPositions.value; if (itemPositions.isEmpty) return; @@ -1273,62 +1287,11 @@ class _StreamMessageListViewState extends State { isLastItemFullyVisible = (lastItemPosition.contentLeadingEdge ?? lastItemPosition.itemLeadingEdge) >= 0; } - 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; - - final lastFullyVisibleMessageChanged = switch (_lastFullyVisibleMessage) { - final message? => message.id != newLastFullyVisibleMessage?.id, - null => true, // Allows setting the initial value. - }; - - // 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; - - // Mark messages as read if needed. - if (_config.markReadWhenAtTheBottom) { - _maybeMarkMessagesAsRead().ignore(); - } - } - } - - // 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. - // - // If any of the conditions are not met, the function returns early. - // Otherwise, it calls the _markMessagesAsRead function to mark the messages - // as read. - 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; - - final isUpToDate = channel.state?.isUpToDate ?? false; - if (!isInThread && !isUpToDate) return; - - final hasUnread = (channel.state?.unreadCount ?? 0) > 0; - if (!isInThread && !hasUnread) return; - - // Mark messages as read if it's allowed. - return _debouncedMarkMessagesAsRead(); + _showScrollToBottom.value = !isLastItemFullyVisible; + _unreadController.handleItemPositionsChanged( + itemPositions, + isAtBottom: isLastItemFullyVisible, + ); } 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 04909f5620..cb4d29673c 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..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; 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..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 @@ -5,11 +5,19 @@ 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. /// -/// [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. +/// 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} /// @@ -37,12 +45,23 @@ class UnreadIndicatorButton extends StatelessWidget { super.key, required this.onJumpTap, required this.onDismissTap, + this.unreadCount, }); + /// The unread count to display, when the host owns the pill's visibility. + /// + /// 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. /// - /// Receives the ID of the last message the current user has read, - /// which can be used to scroll to that position. + /// 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. @@ -50,8 +69,20 @@ class UnreadIndicatorButton extends StatelessWidget { /// Typically used to mark all messages as read. final Future Function() onDismissTap; + Widget _buildButton(BuildContext context, int count, String? lastReadMessageId) { + return core.StreamJumpToUnreadButton( + 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(); @@ -59,14 +90,9 @@ class UnreadIndicatorButton extends StatelessWidget { 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, - ); + final count = currentUserRead.unreadMessages; + if (count <= 0) return const Empty(); + return _buildButton(context, count, currentUserRead.lastReadMessageId); }, ); } 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 68ff50d32c..c9c28e329f 100644 --- a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart +++ b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart @@ -90,6 +90,7 @@ 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/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/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..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,16 +1,21 @@ // 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`. 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. +// In a thread, it fires `channel.markThreadRead(parentId)` instead, gated +// 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 @@ -22,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'; @@ -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 + // "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,16 @@ void main() { required int unreadCount, bool markReadWhenAtTheBottom = true, Message? parentMessage, + Read? currentUserRead, + bool openAtFirstUnread = false, }) 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) { @@ -110,17 +132,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, - child: StreamMessageListView( - parentMessage: parentMessage, - config: StreamMessageListViewConfiguration( - markReadWhenAtTheBottom: markReadWhenAtTheBottom, + // 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, + ), ), ), ), @@ -131,6 +158,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,13 +174,40 @@ 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, + messages: messages, + isUpToDate: true, + unreadCount: 5, + ); + + verify(() => channel.markRead(messageId: any(named: 'messageId'))).called(1); + }, + ); + + 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); @@ -163,7 +218,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 +237,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 +256,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 +271,88 @@ void main() { ); }, ); + + testWidgets( + 'does NOT fire 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(); + // 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); + }, + ); }); group('thread markThreadRead gates', () { @@ -273,6 +410,55 @@ void main() { }, ); + testWidgets( + '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', + 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), + ); + + // 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: false, + unreadCount: 0, + ); + + verify(() => channel.markThreadRead(parent.id)).called(1); + }, + ); + testWidgets( 'does NOT fire markRead (channel-level) when in a thread', (tester) async { @@ -310,7 +496,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 +505,429 @@ 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( + '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 { + 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(null)); + await tester.pumpAndSettle(); + + verify( + () => channel.query( + preferOffline: false, + messagesPagination: const PaginationParams(limit: 30, idAround: lastReadMessageId), + ), + ).called(1); + }, + ); + }); + + 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<({List messages, Read markedRead})> 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: messages, markedRead: markedRead); + } + + 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, :markedRead) = 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); + // 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 { + 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); + }, + ); + + 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', () { + 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); }, ); }); @@ -330,7 +937,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 +946,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 +971,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 +980,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 +1003,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/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..ec5ea5e9ed --- /dev/null +++ b/packages/stream_chat_flutter/test/src/message_list_view/message_list_unread_controller_test.dart @@ -0,0 +1,675 @@ +// 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: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'; +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', () { + when(() => channelState.unreadCount).thenReturn(3); + when(() => channelState.currentUserRead).thenReturn(read(unreadMessages: 3)); + messages = [message(id: 'newest')]; + + 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); + }); + }); + + 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', () { + 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 new file mode 100644 index 0000000000..095847065d --- /dev/null +++ b/packages/stream_chat_flutter/test/src/message_list_view/unread_divider_test.dart @@ -0,0 +1,746 @@ +// Tests for the unread-messages divider, the jump-to-unread pill, and the +// scroll-to-bottom badge. +// +// - 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, 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); + + // 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); + }, + ); + + 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('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 + // 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 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( + 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', + (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); + + // 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'); + }, + ); + + 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/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..dd56cb64a5 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/message_list_view/unread_indicator_button_test.dart @@ -0,0 +1,135 @@ +// 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; + }, + ); + + 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'); + }); + }); + + 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_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)); diff --git a/packages/stream_chat_flutter_core/CHANGELOG.md b/packages/stream_chat_flutter_core/CHANGELOG.md index 5b4b112243..eba3b1b97c 100644 --- a/packages/stream_chat_flutter_core/CHANGELOG.md +++ b/packages/stream_chat_flutter_core/CHANGELOG.md @@ -10,6 +10,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. - Added `search()`, `searchWithFilter()`, and `clearResults()` to `StreamMessageSearchListController`, `StreamUserListController`, and `StreamMemberListController`. `search()`/`searchWithFilter()` debounce reloads by the search-text length (a filter with no search text reloads immediately) and drop superseded results; `clearResults()` cancels any pending search and clears the results. 🐞 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..eb0b6a7dc7 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,21 @@ 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. + /// + /// 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. /// /// Defaults to a builder that resolves the nearest @@ -856,33 +873,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/packages/stream_chat_flutter_core/test/stream_channel_test.dart b/packages/stream_chat_flutter_core/test/stream_channel_test.dart index 15c29bb8a2..0699c2b045 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,105 @@ 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( + '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 { + 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 404aae0b92..3445a19348 100644 --- a/packages/stream_chat_localizations/CHANGELOG.md +++ b/packages/stream_chat_localizations/CHANGELOG.md @@ -9,6 +9,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` 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 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.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations.dart index ab65cd486a..2701211722 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,23 @@ 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". + /// + /// 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 + 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 063cd39e74..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 @@ -495,6 +495,16 @@ class StreamChatLocalizationsCa extends GlobalStreamChatLocalizations { @override String unreadMessagesSeparatorText() => 'Missatges nous'; + @override + String unreadMessagesSeparatorLabel({required int count}) { + return Intl.plural( + count, + one: '$count missatge no llegit', + other: '$count missatges no llegits', + locale: localeName, + ); + } + @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..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 @@ -492,6 +492,16 @@ class StreamChatLocalizationsDe extends GlobalStreamChatLocalizations { @override String unreadMessagesSeparatorText() => 'Neue Nachrichten'; + @override + String unreadMessagesSeparatorLabel({required int count}) { + return Intl.plural( + count, + one: '$count ungelesene Nachricht', + other: '$count ungelesene Nachrichten', + locale: localeName, + ); + } + @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..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 @@ -494,6 +494,16 @@ class StreamChatLocalizationsEn extends GlobalStreamChatLocalizations { @override String unreadMessagesSeparatorText() => 'New messages'; + @override + String unreadMessagesSeparatorLabel({required int count}) { + return Intl.plural( + count, + one: '$count unread message', + other: '$count unread messages', + locale: localeName, + ); + } + @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..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 @@ -498,6 +498,16 @@ No es posible añadir más de $limit archivos adjuntos @override String unreadMessagesSeparatorText() => 'Nuevos mensajes'; + @override + String unreadMessagesSeparatorLabel({required int count}) { + return Intl.plural( + count, + one: '$count mensaje sin leer', + other: '$count mensajes sin leer', + locale: localeName, + ); + } + @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..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 @@ -498,6 +498,16 @@ 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}) { + return Intl.plural( + count, + one: '$count message non lu', + other: '$count messages non lus', + locale: localeName, + ); + } + @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..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 @@ -496,6 +496,16 @@ class StreamChatLocalizationsHi extends GlobalStreamChatLocalizations { @override String unreadMessagesSeparatorText() => 'नए संदेश।'; + @override + String unreadMessagesSeparatorLabel({required int count}) { + return Intl.plural( + count, + one: '$count अपठित संदेश', + other: '$count अपठित संदेश', + locale: localeName, + ); + } + @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..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 @@ -501,6 +501,16 @@ Attenzione: il limite massimo di $limit file è stato superato. @override String unreadMessagesSeparatorText() => 'Nuovi messaggi'; + @override + String unreadMessagesSeparatorLabel({required int count}) { + return Intl.plural( + count, + one: '$count messaggio non letto', + other: '$count messaggi non letti', + locale: localeName, + ); + } + @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..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 @@ -484,6 +484,16 @@ class StreamChatLocalizationsJa extends GlobalStreamChatLocalizations { @override String unreadMessagesSeparatorText() => '新しいメッセージ。'; + @override + String unreadMessagesSeparatorLabel({required int count}) { + return Intl.plural( + count, + one: '$count件の未読メッセージ', + other: '$count件の未読メッセージ', + locale: localeName, + ); + } + @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..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 @@ -485,6 +485,16 @@ class StreamChatLocalizationsKo extends GlobalStreamChatLocalizations { @override String unreadMessagesSeparatorText() => '새 메시지.'; + @override + String unreadMessagesSeparatorLabel({required int count}) { + return Intl.plural( + count, + one: '읽지 않은 메시지 $count개', + other: '읽지 않은 메시지 $count개', + locale: localeName, + ); + } + @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..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 @@ -436,6 +436,16 @@ class StreamChatLocalizationsNo extends GlobalStreamChatLocalizations { @override String unreadMessagesSeparatorText() => 'Nye meldinger.'; + @override + String unreadMessagesSeparatorLabel({required int count}) { + return Intl.plural( + count, + one: '$count ulest melding', + other: '$count uleste meldinger', + locale: localeName, + ); + } + @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..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 @@ -497,6 +497,16 @@ Não é possível adicionar mais de $limit arquivos de uma vez @override String unreadMessagesSeparatorText() => 'Novas mensagens'; + @override + String unreadMessagesSeparatorLabel({required int count}) { + return Intl.plural( + count, + one: '$count mensagem não lida', + other: '$count mensagens não lidas', + locale: localeName, + ); + } + @override String get enableFileAccessMessage => 'Ative o acesso aos arquivos para poder compartilhá-los com amigos.'; 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', + ); + }, + ); } diff --git a/packages/stream_chat_localizations/test/translations_test.dart b/packages/stream_chat_localizations/test/translations_test.dart index a763436c90..a2dd1e9e2b 100644 --- a/packages/stream_chat_localizations/test/translations_test.dart +++ b/packages/stream_chat_localizations/test/translations_test.dart @@ -225,11 +225,25 @@ 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); expect(localizations.unreadCountIndicatorLabel(unreadCount: 2), isNotNull); - expect(localizations.unreadMessagesSeparatorText(), 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 diff --git a/sample_app/lib/routes/app_routes.dart b/sample_app/lib/routes/app_routes.dart index c6cb1631a0..7a59ba3ed2 100644 --- a/sample_app/lib/routes/app_routes.dart +++ b/sample_app/lib/routes/app_routes.dart @@ -40,6 +40,7 @@ final appRoutes = [ return StreamChannel( channel: channel, initialMessageId: messageId, + openAtFirstUnread: false, child: Builder( builder: (context) { return (parentMessage != null)