diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index 5e45074a7..8665c6a3b 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -18,7 +18,7 @@ - `Translations.unreadMessagesSeparatorLabel` is a new interface member. Classes that `extends Translations` (or `GlobalStreamChatLocalizations`) inherit the fallback and need no change, but a class that `implements` either interface directly must add this member — Dart does not inherit method bodies through `implements`. Forward it to your existing `unreadMessagesSeparatorText()` to keep the previous copy. - Changed the "↑ N unread" jump-to-unread pill to a count fixed when the channel opens, shown as soon as that count is known and dismissed permanently for the session once tapped, dismissed, or scrolled past. - Changed the scroll-to-bottom badge to count only messages that arrive out of view during the current session, rather than being seeded from the channel's unread count. It always resets to 0 once the user reaches the bottom. -- Changed the "unread messages" divider to show a count, starting at the channel's open-time unread total and counting up as further messages arrive during the session — mirroring WhatsApp — instead of a fixed, count-less label. +- Changed the "unread messages" divider to show a count, starting at the channel's open-time unread total and counting up as further messages arrive during the session, instead of a fixed, count-less label. - Tightened `StreamMessageListView`'s automatic mark-read gating to also require that the pre-existing unread boundary has been seen or scrolled past, and that there's no pending manual mark-unread. Channels with no boundary to reach — opened fully read, never opened at all, or tracking unread locally — are unaffected. ⚠️ Deprecated diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_unread_controller.dart b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_unread_controller.dart new file mode 100644 index 000000000..8aa6bc161 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_unread_controller.dart @@ -0,0 +1,744 @@ +import 'dart:math'; + +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:stream_chat_flutter/scrollable_positioned_list/scrollable_positioned_list.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +// The current user's read boundary: where in the channel they had read up to. +// A mark-unread moves it backward, which is how a fresh one is told apart +// from the read-stream emissions that keep arriving during a session. +// +// A record rather than a class so equality stays structural — the whole point +// is comparing a newly observed boundary against the previous one. +typedef _ReadBoundary = ({DateTime? lastRead, String? lastReadMessageId}); + +// Every input the mark-read gate reads, captured as the key of one attempt. +// Comparing a new key against the last one is what stops a mark-read that +// keeps failing from being retried on every scroll frame, while still letting +// a genuine change through. +// +// Also a record for its structural equality, which the comparison relies on. +typedef _MarkReadAttempt = ({ + String? newestMessageId, + int unreadCount, + bool isMarkedAsUnread, + bool viewportDiverged, +}); + +/// Owns the unread-message state machine behind [StreamMessageListView]: +/// the unread messages divider and its floating pill, the scroll-to-bottom +/// badge count, and the auto mark-read gate (including protection for an +/// active manual mark-unread). +/// +/// The list forwards raw signals in — channel attach, message arrivals, item +/// position ticks, read-stream emissions and pill taps — and renders from the +/// exposed [ValueListenable]s. This class never touches the widget tree, and +/// reaches the list's live state only through the accessors it is given. +@internal +class MessageListUnreadController { + /// Creates a controller wired to the message list's live state. + /// + /// Every dependency is a function rather than a value because the state + /// behind it changes over the list's lifetime: the channel is reassigned on + /// a channel change, `messages` on every stream emission, and the widget's + /// configuration on any rebuild. + MessageListUnreadController({ + required Channel? Function() channel, + required Message? Function(Read? currentUserRead) getFirstUnreadMessage, + required Message? Function() parentMessage, + required List Function() messages, + required Iterable Function() itemPositions, + required bool Function() markReadWhenAtTheBottom, + required Future Function(String messageId) scrollToMessage, + required Object? Function() attachToken, + }) : _channel = channel, + _getFirstUnreadMessage = getFirstUnreadMessage, + _parentMessage = parentMessage, + _messages = messages, + _itemPositions = itemPositions, + _markReadWhenAtTheBottom = markReadWhenAtTheBottom, + _scrollToMessage = scrollToMessage, + _attachToken = attachToken; + + final Channel? Function() _channel; + final Message? Function(Read? currentUserRead) _getFirstUnreadMessage; + final Message? Function() _parentMessage; + final List Function() _messages; + final Iterable Function() _itemPositions; + final bool Function() _markReadWhenAtTheBottom; + + // Scrolls the list to a message without highlighting it, reporting whether + // the scroll actually landed. + final Future Function(String messageId) _scrollToMessage; + + // Identity of the current channel attachment, compared across awaits so a + // result that arrives after the list re-attached elsewhere is dropped. + final Object? Function() _attachToken; + + bool get _isThreadConversation => _parentMessage() != null; + + bool _disposed = false; + + // --- Divider A: pre-existing unread, frozen at channel open --- + // + // [_unreadBaseline] is the current user's [Read] captured once when the + // channel is attached (or on the first `currentUserReadStream` emission if + // read state wasn't available yet). It never changes afterwards, so + // resolving the anchor against it — rather than against the live, + // ever-shrinking `unreadCount` — is what keeps the divider and pill on + // screen across an auto mark-read. + Read? _unreadBaseline; + bool _unreadBaselineCaptured = false; + + // Resolved anchor for divider A. The anchor (and `count`, the frozen + // baseline used by the pill) is frozen once non-null: recomputation is + // skipped as soon as `anchorId` is set. May take a few rebuilds to resolve + // if top pagination hasn't finished loading the boundary yet. + final _unreadDivider = ValueNotifier<({int count, String? anchorId})>((count: 0, anchorId: null)); + + // Grows by one for every message that arrives out of view while divider + // A is on screen, so the divider's displayed count keeps counting up + // during the session instead of staying frozen at the open-time count. + // Added on top of `_unreadDivider.value.count` for display only — the + // pill keeps using the frozen count. + final ValueNotifier _unreadDividerGrowth = ValueNotifier(0); + + // Sticky: becomes true once the user has seen (rendered) or scrolled past + // divider A's anchor. Drives the pill's permanent dismissal and (see + // [_maybeMarkMessagesAsRead]) gates auto mark-read. + final ValueNotifier _hasSeenFirstUnread = ValueNotifier(false); + + // Whether the list has reported item positions at least once. + // + // The pill waits on this. Its count is published synchronously while the + // channel is attached, but `_hasSeenFirstUnread` can only be decided from + // item positions, which arrive in a post-frame callback — so without this + // the pill paints for exactly one frame on every channel opened at its + // first unread message, then disappears. + final ValueNotifier _hasLaidOut = ValueNotifier(false); + + // Scroll-to-bottom badge count. Counts messages that arrive while the user + // is scrolled away from the bottom; resets to 0 once they reach the bottom. + final ValueNotifier _scrollToBottomBadge = ValueNotifier(0); + + // Sticky "bottom was reached" flag for the mark-read gate. Cleared after + // each successful mark-read so returning to the bottom is required again + // before the next one. + bool _hasSeenLastMessage = false; + + // While non-null, the viewport captured at the moment an active manual + // mark-unread (`channel.state.isMarkedAsUnread`) was first observed. + // `_maybeMarkMessagesAsRead` blocks until [_markUnreadViewportDiverged] + // is true — evidence the user did something (scrolled, reopened the + // channel, etc.) since marking the message unread, rather than the + // anchor merely being immediately "visible" again because it's usually + // the very message just marked and nothing has moved. + // + // This can't gate on `isMarkedAsUnread` directly and permanently: that + // flag only clears via a successful mark-read, which is the very thing + // it would be gating, so treating it as a persistent block would + // deadlock the channel unread forever the moment it's set — the exact + // bug this snapshot exists to avoid. + // + // Set eagerly in [handleCurrentUserReadChanged] right when a live + // transition is observed (captures the precise pre-scroll viewport), and + // in [handleItemPositionsChanged] on the first genuinely laid-out frame + // as a fallback for when the channel simply mounts with + // `isMarkedAsUnread` already true and no transition ever fires — that + // has to happen there and not lazily inside [_maybeMarkMessagesAsRead], + // since the first time that gate is evaluated might already be the + // user's first genuine arrival at the bottom, which would otherwise be + // burned on capturing the baseline instead of acting on it. Cleared once + // a mark-read actually goes through, or once `isMarkedAsUnread` itself + // clears (so a future mark-unread starts its own fresh snapshot). + // + // Holds visible item *indices* rather than full [ItemPosition]s: comparing + // full positions would latch divergence on a sub-pixel edge change from an + // unrelated relayout (async attachment sizing, keyboard inset, image + // load) even though the user never scrolled, undoing the manual + // mark-unread almost instantly. + List? _markUnreadViewportSnapshot; + + // Sticky once true: sighted the first time [handleItemPositionsChanged] + // (or, as a fallback, [_maybeMarkMessagesAsRead] itself) sees item + // positions that genuinely differ from [_markUnreadViewportSnapshot]. + // Deliberately tracked as "did this ever happen" rather than + // re-comparing the *current* positions against the snapshot on each + // check — a user who scrolls away and back settles at the exact same + // rest position, which would otherwise look unchanged and re-block a + // mark-read that should already have been earned by that round trip. + bool _markUnreadViewportDiverged = false; + + // State the last mark-read attempt was made against. Item positions tick + // on every scroll frame, so without this a mark-read that keeps failing + // would be retried for as long as the user keeps scrolling at the bottom + // (once a second, as bounded by the debounce). Every input the gate in + // [_maybeMarkMessagesAsRead] actually reads is part of the key, so a + // genuine change — a new message, a mark-unread, the viewport diverging + // after one — still gets its attempt. + _MarkReadAttempt? _lastMarkReadAttempt; + + // Previous value of `channel.state.isMarkedAsUnread`, so + // [handleCurrentUserReadChanged] can act on a new mark-unread rather than + // on every read-stream emission that happens while the flag stays set. + // Seeded from the channel on attach, since it can already be set there. + bool _wasMarkedAsUnread = false; + + // Read boundary observed alongside [_wasMarkedAsUnread]. A mark-unread + // moves the boundary backward, so a change here while the flag is already + // set is how a *second* mark-unread is told apart from the read-stream + // emissions that keep arriving during one. + _ReadBoundary? _lastReadBoundary; + + static _ReadBoundary? _readBoundaryOf(Read? read) { + if (read == null) return null; + return (lastRead: read.lastRead, lastReadMessageId: read.lastReadMessageId); + } + + // Whether divider A's current session came from an explicit mark-unread + // rather than from pre-existing unread at channel open. The anchor of a + // manual mark-unread is the message the user was looking at when they + // marked it, so it's already on screen — see + // [_maybeUpdateHasSeenFirstUnread] for why that changes what counts as + // having reached the boundary. + bool _unreadFromManualMarkUnread = false; + + /// The unread divider's frozen open-time count and, once resolved, the id + /// of the message it anchors to. + ValueListenable<({int count, String? anchorId})> get unreadDivider => _unreadDivider; + + /// Qualifying messages that have arrived since the divider's baseline was + /// frozen, added on top of [unreadDivider]'s count for display. + ValueListenable get unreadDividerGrowth => _unreadDividerGrowth; + + /// Whether the divider's anchor has been seen or scrolled past. + ValueListenable get hasSeenFirstUnread => _hasSeenFirstUnread; + + /// Whether the list has reported item positions at least once. + ValueListenable get hasLaidOut => _hasLaidOut; + + /// Messages that arrived while the user was scrolled away from the bottom. + ValueListenable get scrollToBottomBadge => _scrollToBottomBadge; + + /// Whether a baseline was captured but its anchor still needs resolving, + /// meaning [resolveDividerAnchor] is worth retrying once layout settles. + bool get needsAnchorResolution => _unreadBaseline != null && _unreadDivider.value.anchorId == null; + + // Debounced channel mark-read. + late final _debouncedMarkRead = debounce( + ([String? id]) => _channel()?.markRead(messageId: id), + const Duration(seconds: 1), + leading: true, + ); + + // Debounced thread mark-read. + late final _debouncedMarkThreadRead = debounce( + (String parentId) => _channel()?.markThreadRead(parentId), + const Duration(seconds: 1), + leading: true, + ); + + /// Resets every piece of unread state for a newly attached channel. + /// + /// Must be called after the new channel is reachable through the accessors + /// this controller was given, and before subscribing to that channel's read + /// stream — the seeding below exists precisely to survive that stream's + /// immediate replay. + void attach() { + _debouncedMarkRead.cancel(); + _debouncedMarkThreadRead.cancel(); + + final channelState = _channel()?.state; + + _unreadBaselineCaptured = false; + _unreadBaseline = null; + _unreadDivider.value = (count: 0, anchorId: null); + _unreadDividerGrowth.value = 0; + _scrollToBottomBadge.value = 0; + _hasSeenFirstUnread.value = false; + _hasSeenLastMessage = false; + _hasLaidOut.value = false; + _lastMarkReadAttempt = null; + _markUnreadViewportSnapshot = null; + _markUnreadViewportDiverged = false; + // Seeded from the channel's own state rather than hardcoded to false: + // a channel can mount with a manual mark-unread already active (mark + // unread, leave, come back — the flag lives on the cached + // `ChannelClientState`). `currentUserReadStream` is backed by a + // `BehaviorSubject`, so the subscription the list sets up replays the + // current value straight away; without this seed that replay would read + // as a brand-new mark-unread and restart a session that never ended. + _wasMarkedAsUnread = channelState?.isMarkedAsUnread ?? false; + _lastReadBoundary = _readBoundaryOf(channelState?.currentUserRead); + _unreadFromManualMarkUnread = _wasMarkedAsUnread; + _captureUnreadBaselineIfNeeded(); + } + + // Captures [_unreadBaseline] the first time the current user's read state + // becomes available, then attempts to resolve divider A's anchor against + // it. No-ops in a thread, where divider A doesn't apply. + void _captureUnreadBaselineIfNeeded() { + if (_unreadBaselineCaptured || _isThreadConversation) return; + + final currentUserRead = _channel()?.state?.currentUserRead; + if (currentUserRead == null) return; + + _unreadBaselineCaptured = true; + _unreadBaseline = currentUserRead.unreadMessages > 0 ? currentUserRead : null; + // Publish the frozen count right away, even though the anchor itself + // can't resolve until top pagination has loaded that far back — the + // pill only needs the count, not the anchor, so it shouldn't wait on + // pagination to appear (see [onPillJumpTapped] for how a tap before the + // anchor resolves still jumps there). + if (_unreadBaseline case final baseline?) { + _unreadDivider.value = (count: baseline.unreadMessages, anchorId: _unreadDivider.value.anchorId); + } + resolveDividerAnchor(); + } + + /// Resolves divider A's anchor against the frozen baseline. A no-op once + /// resolved, and while top pagination hasn't loaded the boundary yet. + void resolveDividerAnchor() { + if (_isThreadConversation || _unreadDivider.value.anchorId != null) return; + + final baseline = _unreadBaseline; + if (baseline == null) return; + + final anchor = _getFirstUnreadMessage(baseline); + if (anchor == null) return; + + _unreadDivider.value = (count: baseline.unreadMessages, anchorId: anchor.id); + } + + /// Reacts to a `currentUserReadStream` emission. An explicit mark-unread + /// moves the read boundary backward — treat it as a new session start for + /// divider A/the pill. + /// + /// The reset is deliberately gated on a *new* mark-unread — the flag + /// turning on, or the read boundary moving again while it's already on — + /// rather than on the flag merely being set: the read stream also emits + /// while it stays set (every new message, for one), and re-running the + /// reset then would clear `_hasSeenFirstUnread` again and flicker the pill + /// back in and straight out on each arrival. An arriving message bumps + /// `unreadMessages` but leaves the boundary untouched, so gating on the + /// boundary avoids that flicker while still catching a second mark-unread. + /// + /// Watching the boundary rather than only the transition matters because + /// `isMarkedAsUnread` is cleared solely by a mark-read (see + /// `ChannelClientState.markReadLocally`), and both the baseline capture and + /// [resolveDividerAnchor] freeze once resolved — so this reset is the only + /// thing that can move divider A once a mark-unread session is under way. + void handleCurrentUserReadChanged() { + if (_isThreadConversation) return; + + final channel = _channel(); + if (channel == null) return; + + final isMarkedAsUnread = channel.state?.isMarkedAsUnread ?? false; + final readBoundary = _readBoundaryOf(channel.state?.currentUserRead); + final boundaryMoved = readBoundary != _lastReadBoundary; + final justMarkedAsUnread = isMarkedAsUnread && (!_wasMarkedAsUnread || boundaryMoved); + _wasMarkedAsUnread = isMarkedAsUnread; + _lastReadBoundary = readBoundary; + + if (justMarkedAsUnread) { + _unreadBaselineCaptured = false; + _unreadBaseline = null; + _unreadDivider.value = (count: 0, anchorId: null); + _unreadDividerGrowth.value = 0; + _hasSeenFirstUnread.value = false; + _unreadFromManualMarkUnread = true; + // Each mark-unread starts its own session, so the snapshot is taken + // fresh here rather than kept from a previous one — but only from a + // viewport that has actually been laid out. `itemPositions` is still + // empty before the first frame, and capturing that would make the + // very first laid-out frame look like divergence, defeating guard 4 + // and leaving the fallback in [handleItemPositionsChanged] + // unreachable. Left null instead, for that fallback to fill in. + final visibleIndices = _itemPositions().map((it) => it.index).toList(); + _markUnreadViewportSnapshot = visibleIndices.isEmpty ? null : visibleIndices; + _markUnreadViewportDiverged = false; + } else if (!isMarkedAsUnread) { + _unreadFromManualMarkUnread = false; + _markUnreadViewportSnapshot = null; + _markUnreadViewportDiverged = false; + } + + _captureUnreadBaselineIfNeeded(); + } + + /// Counts a freshly arrived [message] towards the divider's growing count + /// and, when the user isn't at the bottom, the scroll-to-bottom badge. + /// + /// Qualifying arrivals are filtered the same way the channel's own unread + /// count filters them, so silent, shadowed, ephemeral, thread-only, + /// restricted, muted-sender and own messages don't inflate either counter. + /// The badge and divider also only apply to the channel's message stream, + /// never to thread replies. + void handleMessageArrived( + Message message, { + required OwnUser? currentUser, + required bool isAtBottom, + }) { + final countsAsUnread = _countsTowardsUnreadIndicators(message, currentUser); + if (_isThreadConversation || !countsAsUnread) return; + + // The divider counts every qualifying arrival — including ones seen + // live at the bottom — so it keeps counting up for the whole session. + // The badge is narrower: it only exists to flag what was missed while + // scrolled away, so it skips arrivals that were already in view and + // resets once the bottom is reached (see [handleItemPositionsChanged]). + _unreadDividerGrowth.value += 1; + if (!isAtBottom) _scrollToBottomBadge.value += 1; + } + + /// Processes an item-positions tick, with [isAtBottom] reporting whether + /// the newest message is fully visible. + void handleItemPositionsChanged( + Iterable itemPositions, { + required bool isAtBottom, + }) { + // Guarded here as well as at the call site: an empty viewport is not a + // laid-out one, and letting it through would both flip [hasLaidOut] on a + // frame that renders nothing and let + // [_checkMarkUnreadViewportDivergence] snapshot an empty index set — + // which the very next non-empty frame would read as divergence, undoing + // an active manual mark-unread. See [handleCurrentUserReadChanged], + // which avoids capturing that same empty viewport for this reason. + if (itemPositions.isEmpty) return; + + _hasLaidOut.value = true; + + // Snapshot the viewport (or check it against an existing snapshot for + // divergence) the first time it's genuinely laid out while marked as + // unread, in case the channel simply mounted in that state rather than + // [handleCurrentUserReadChanged] observing a live transition to hook + // the snapshot on. Doing this here — on every non-empty layout, before + // checking anything else below — rather than lazily inside + // [_maybeMarkMessagesAsRead], matters: that gate is only ever evaluated + // when a mark-read could fire, which for a channel the user opens and + // immediately scrolls all the way through might be the very first time + // they reach the bottom. Capturing the baseline there would burn that + // first genuine read on the snapshot itself instead of acting on it. + if (_channel()?.state?.isMarkedAsUnread ?? false) { + _checkMarkUnreadViewportDivergence(itemPositions); + } + + final justSeenFirstUnread = _maybeUpdateHasSeenFirstUnread(itemPositions); + + if (isAtBottom) { + _hasSeenLastMessage = true; + _scrollToBottomBadge.value = 0; + } + + // Attempt a mark-read whenever either half of the gate could have just + // become satisfied; [_maybeMarkMessagesAsRead] does the actual deciding. + if ((isAtBottom || justSeenFirstUnread) && _markReadWhenAtTheBottom()) { + _maybeMarkMessagesAsRead(isAtBottom: isAtBottom).ignore(); + } + } + + /// Handles a tap on the pill's jump affordance. + Future onPillJumpTapped() async { + // The anchor may not have resolved yet if top pagination hasn't loaded + // that far back — the pill is visible already (see its gating in the + // list), so fall back to the frozen baseline's own last-read boundary, + // known immediately from the server `Read`, rather than doing nothing. + final anchorId = _unreadDivider.value.anchorId ?? _unreadBaseline?.lastReadMessageId; + + // A channel the user has never opened reports unread messages but has + // no read boundary at all: the anchor can't resolve until top + // pagination ends, and there's no `lastReadMessageId` to fall back on + // either. Everything loaded is unread, so head for the oldest message + // currently loaded — as far back as the boundary can be, and it pulls + // the next page in on arrival — rather than leaving the tap inert. + // + // `_hasSeenFirstUnread` is deliberately not latched here: the real + // boundary is further back than where this lands, so the pill stays up + // until it's genuinely reached. + if (anchorId == null) { + final oldestLoaded = _messages().lastOrNull; + if (oldestLoaded == null) return; + await _scrollToMessage(oldestLoaded.id); + return; + } + + // Delegates to the list's scroll-to-message, which falls back to + // [StreamChannelState.loadChannelAtMessage] when the anchor isn't in the + // currently loaded window — after which the real anchor resolves + // naturally via the list's retry of [resolveDividerAnchor], rendering + // divider A too. That can await pagination and a frame, and this + // controller survives a channel change — so remember which channel the + // tap was for and drop the result if it isn't the current one any more. + final tappedFor = _attachToken(); + final didJump = await _scrollToMessage(anchorId); + if (_disposed || _attachToken() != tappedFor) return; + // Only claim the boundary as seen once the jump actually landed — + // otherwise (message not found even after pagination, or the list not + // attached) the pill would vanish and the mark-read gate would open for + // a boundary the user never actually reached. + if (didJump) _hasSeenFirstUnread.value = true; + } + + /// Handles a tap on the pill's dismiss affordance. + Future onPillDismissTapped() async { + _hasSeenFirstUnread.value = true; + // Dismissing is a local decision; if the request behind it fails there + // is nothing to show the user, and letting it escape here would surface + // as an unhandled async error instead. + markAsRead().ignore(); + } + + /// Marks the channel — or, in a thread, the thread — as read immediately, + /// bypassing the debouncers. + Future markAsRead() async { + if (_parentMessage() case final parent?) { + // If we are in a thread, mark the thread as read immediately. + await _channel()?.markThreadRead(parent.id); + return; + } + + // Otherwise, mark the channel as read immediately. + await _channel()?.markRead(); + } + + Future _debouncedMarkMessagesAsRead() async { + if (_parentMessage() case final parent?) { + // If we are in a thread, mark the thread as read. + _debouncedMarkThreadRead.call([parent.id]); + } else { + // Otherwise, mark the channel as read. + _debouncedMarkRead.call(); + } + } + + // Whether a freshly-arrived [message] should bump the scroll-to-bottom + // badge and divider A's growing count. + // + // This is the message- and sender-level half of + // [MessageRules.canCountAsUnread], which is what keeps silent, shadowed, + // ephemeral, thread-only, restricted, muted-sender and own messages from + // inflating either counter. + // + // The channel-level half of that rule (`isMuted`, `canUseReadReceipts`, + // `usesLocalUnreadCount`) is deliberately left out. Those govern whether + // the server tracks an unread count for the channel at all, whereas these + // two counters are purely local "what arrived while you weren't looking" + // indicators that should keep working either way — and + // `usesLocalUnreadCount` is an extension getter reading `Channel`'s + // private client field, so it can't be resolved against a channel double + // at all. + // + // The user-level half (`isReadReceiptsEnabled`) is *not* left out: it + // isn't about how the channel is configured but about the user opting out + // of unread tracking entirely, and honouring it here is what keeps these + // indicators from counting up while the channel itself reports zero. + bool _countsTowardsUnreadIndicators(Message message, OwnUser? currentUser) { + if (currentUser == null) return false; + if (!currentUser.isReadReceiptsEnabled) return false; + + if (message.silent) return false; + if (message.shadowed) return false; + if (message.isEphemeral) return false; + + // Thread replies don't count towards the channel's unread state unless + // they were explicitly also sent to the channel. + if (message.parentId != null && message.showInChannel != true) return false; + + final sender = message.user; + if (sender == null) return false; + if (sender.id == currentUser.id) return false; + + if (message.isNotVisibleTo(currentUser.id)) return false; + + final isSenderMuted = currentUser.mutes.any((it) => it.target.id == sender.id); + if (isSenderMuted) return false; + + return true; + } + + // Captures [_markUnreadViewportSnapshot] the first time this is called, + // and otherwise checks [itemPositions] against it, latching + // [_markUnreadViewportDiverged] the first time they genuinely differ. + // Deliberately latching rather than re-comparing *current* positions + // against the snapshot on every check: a user who scrolls away and back + // settles at the exact same rest position, which would otherwise look + // unchanged and re-block a mark-read the round trip should already have + // earned. Safe to call on every position-changed tick — a no-op once + // already diverged. + // + // Compares the set of visible item *indices* rather than full + // [ItemPosition]s (which also carry leading/trailing edge offsets) — an + // unrelated relayout that nudges an edge by a fraction of a pixel isn't + // evidence the user did anything, and shouldn't count as divergence. + void _checkMarkUnreadViewportDivergence(Iterable itemPositions) { + final visibleIndices = itemPositions.map((it) => it.index).toList(); + + if (_markUnreadViewportSnapshot == null) { + _markUnreadViewportSnapshot = visibleIndices; + return; + } + if (_markUnreadViewportDiverged) return; + + const indicesEquality = UnorderedIterableEquality(); + if (!indicesEquality.equals(visibleIndices, _markUnreadViewportSnapshot)) { + _markUnreadViewportDiverged = true; + } + } + + // Marks divider A's anchor as seen once it renders on screen, or once the + // user scrolls past it without it ever rendering (a fast fling can skip + // intermediate frames). Sticky: never reverts once true, and reset only + // when the baseline is recaptured (channel change, or an explicit + // mark-unread — see [handleCurrentUserReadChanged]). + // + // Sessions started by an explicit mark-unread require scrolling *past* + // the anchor, since it starts out on screen — see + // [_unreadFromManualMarkUnread]. + // + // Returns true iff this call flips [_hasSeenFirstUnread] from false to + // true. + bool _maybeUpdateHasSeenFirstUnread(Iterable itemPositions) { + if (_isThreadConversation || _hasSeenFirstUnread.value) return false; + + final anchorId = _unreadDivider.value.anchorId; + if (anchorId == null) return false; + + final anchorMessageIndex = _messages().indexWhere((it) => it.id == anchorId); + if (anchorMessageIndex == -1) return false; + final anchorItemIndex = anchorMessageIndex + 2; + + final visibleIndices = itemPositions.map((position) => position.index).toList(); + if (visibleIndices.isEmpty) return false; + + // Smaller item indices are newer. That is a property of the + // index-to-message mapping (`messages[i - 2]`, newest first), not of the + // scroll direction, so it holds regardless of `config.reverse`. If even + // the newest visible item is older than the anchor, the anchor is no + // longer in view and the user has scrolled back past it into read + // history. + final isScrolledPast = visibleIndices.reduce(min) > anchorItemIndex; + + if (_unreadFromManualMarkUnread) { + // The anchor of a manual mark-unread is the message the user was + // looking at when they marked it, so it's on screen from the outset. + // Counting that sighting would dismiss the pill on the very next + // layout tick — the smallest scroll, or none at all. Only actually + // scrolling past the boundary retires it. + if (!isScrolledPast) return false; + } else if (!visibleIndices.contains(anchorItemIndex) && !isScrolledPast) { + return false; + } + + _hasSeenFirstUnread.value = true; + return true; + } + + // Marks messages as read if the conditions are met. + // + // In a thread: the parent must have at least one reply — the server-side + // thread object doesn't exist until the first reply lands, so + // `markThreadRead` on a reply-less parent 404s. A thread read is + // independent of where the parent channel's own loaded window sits. + // + // In the channel, all of: + // 1. The newest page is loaded (`isUpToDate`). + // 2. There is something unread to mark. + // 3. The bottom has been seen — either it's visible now ([isAtBottom]), + // or it was visible earlier and the user has since scrolled away + // (`hasSeenLastMessage`). + // 4. If there's an active manual mark-unread (`isMarkedAsUnread`), the + // viewport must genuinely differ from the one snapshotted when it + // was first observed (`_markUnreadViewportSnapshot`) — otherwise the + // anchor being immediately "visible" again (it's usually the very + // message just marked, with nothing yet scrolled) would undo the + // user's action instantly. + // 5. Divider A's anchor has actually been seen or scrolled past + // (`hasSeenFirstUnreadMessage`) — trivially satisfied when there is + // no boundary to see in the first place: the channel opened fully + // read, the user has never opened it at all (no `lastReadMessageId`, + // so the anchor could only resolve once top pagination reached the + // very start of the channel — effectively never for real history), + // or the channel uses local unread counts and so has no server read + // state to anchor against. + Future _maybeMarkMessagesAsRead({required bool isAtBottom}) async { + final channel = _channel(); + if (channel == null) return; + + final isInThread = _isThreadConversation; + + if (isInThread) { + // A server-side thread object only exists once the parent has at + // least one reply; markThreadRead on a reply-less parent returns 404. + if ((_parentMessage()?.replyCount ?? 0) == 0) return; + return _debouncedMarkMessagesAsRead(); + } + + final isUpToDate = channel.state?.isUpToDate ?? false; + if (!isUpToDate) return; + + final unreadCount = channel.state?.unreadCount ?? 0; + if (unreadCount <= 0) return; + + // True both when the channel opened fully read (no baseline) and when + // the user has never opened it (a baseline with no `lastReadMessageId`). + // Neither has a boundary the user could reach, so requiring one would + // leave the channel permanently unread — see condition 5 above. + final hasNoUnreadBoundary = _unreadBaselineCaptured && _unreadBaseline?.lastReadMessageId == null; + // Equivalent to `channel.usesLocalUnreadCount`, spelled out via + // `channel.client` rather than `Channel`'s private client field so it + // stays evaluable against a test double that only implements the public + // API surface. + final usesLocalUnreadCount = channel.client.isLocalUnreadCountEnabled && !channel.canUseReadReceipts; + final hasSeenFirstUnreadMessage = hasNoUnreadBoundary || _hasSeenFirstUnread.value || usesLocalUnreadCount; + final isMarkedAsUnread = channel.state?.isMarkedAsUnread ?? false; + final hasSeenLastMessage = _hasSeenLastMessage || isAtBottom; + + if (!hasSeenLastMessage) return; + if (!hasSeenFirstUnreadMessage) return; + + if (isMarkedAsUnread) { + // [handleItemPositionsChanged] already keeps this up to date on + // every position-changed tick; this call only matters as a fallback + // if this is ever reached some other way. See + // `_markUnreadViewportSnapshot`'s doc comment for why the guard has + // to latch on divergence rather than checking `isMarkedAsUnread` + // directly as a persistent gate. + _checkMarkUnreadViewportDivergence(_itemPositions()); + if (!_markUnreadViewportDiverged) return; + } + + // Everything the gate above reads is in the key, so an attempt is only + // skipped when repeating it could not produce a different outcome. + final attempt = ( + newestMessageId: _messages().firstOrNull?.id, + unreadCount: unreadCount, + isMarkedAsUnread: isMarkedAsUnread, + viewportDiverged: _markUnreadViewportDiverged, + ); + if (attempt == _lastMarkReadAttempt) return; + _lastMarkReadAttempt = attempt; + + await _debouncedMarkMessagesAsRead(); + _hasSeenLastMessage = false; + _markUnreadViewportSnapshot = null; + _markUnreadViewportDiverged = false; + } + + /// Cancels the pending debounced mark-reads and disposes the notifiers. + /// + /// The list must tear down anything that could still write to this + /// controller — stream subscriptions, position listeners — before calling + /// this. + void dispose() { + _disposed = true; + _debouncedMarkRead.cancel(); + _debouncedMarkThreadRead.cancel(); + _unreadDivider.dispose(); + _unreadDividerGrowth.dispose(); + _hasSeenFirstUnread.dispose(); + _scrollToBottomBadge.dispose(); + _hasLaidOut.dispose(); + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart index 55c27cfe6..3d3ddca42 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:math'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; @@ -8,6 +7,7 @@ import 'package:rxdart/rxdart.dart'; import 'package:stream_chat_flutter/scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:stream_chat_flutter/src/message_list_view/floating_date_divider.dart'; import 'package:stream_chat_flutter/src/message_list_view/loading_indicator.dart'; +import 'package:stream_chat_flutter/src/message_list_view/message_list_unread_controller.dart'; import 'package:stream_chat_flutter/src/message_list_view/mlv_utils.dart'; import 'package:stream_chat_flutter/src/message_list_view/stream_message_list_empty_state.dart'; import 'package:stream_chat_flutter/src/message_list_view/stream_message_list_skeleton_loading.dart'; @@ -294,223 +294,20 @@ class _StreamMessageListViewState extends State { late final ItemPositionsListener _itemPositionListener; StreamChannelState? streamChannel; - // --- Divider A: pre-existing unread, frozen at channel open --- - // - // [_unreadBaseline] is the current user's [Read] captured once when the - // channel is attached (or on the first `currentUserReadStream` emission if - // read state wasn't available yet). It never changes afterwards, so - // resolving the anchor against it — rather than against the live, - // ever-shrinking `unreadCount` — is what keeps the divider and pill on - // screen across an auto mark-read. - Read? _unreadBaseline; - bool _unreadBaselineCaptured = false; - - // Resolved anchor for divider A. The anchor (and `count`, the frozen - // baseline used by the pill) is frozen once non-null: recomputation is - // skipped as soon as `anchorId` is set. May take a few rebuilds to resolve - // if top pagination hasn't finished loading the boundary yet. - final _unreadDivider = ValueNotifier<({int count, String? anchorId})>((count: 0, anchorId: null)); - - // Grows by one for every message that arrives out of view while divider - // A is on screen, so the divider's displayed count keeps counting up - // during the session (mirroring WhatsApp) instead of staying frozen at - // the open-time count. Added on top of `_unreadDivider.value.count` for - // display only — the pill keeps using the frozen count. - final ValueNotifier _unreadDividerGrowth = ValueNotifier(0); - - // Sticky: becomes true once the user has seen (rendered) or scrolled past - // divider A's anchor. Drives the pill's permanent dismissal and (see - // [_maybeMarkMessagesAsRead]) gates auto mark-read. - final ValueNotifier _hasSeenFirstUnread = ValueNotifier(false); - - // Whether the list has reported item positions at least once. - // - // The pill waits on this. Its count is published synchronously while the - // channel is attached, but `_hasSeenFirstUnread` can only be decided from - // item positions, which arrive in a post-frame callback — so without this - // the pill paints for exactly one frame on every channel opened at its - // first unread message, then disappears. - final ValueNotifier _hasLaidOut = ValueNotifier(false); - - // Scroll-to-bottom badge count. Counts messages that arrive while the user - // is scrolled away from the bottom; resets to 0 once they reach the bottom. - final ValueNotifier _scrollToBottomBadge = ValueNotifier(0); - - // Sticky "bottom was reached" flag for the mark-read gate. Cleared after - // each successful mark-read so returning to the bottom is required again - // before the next one. - bool _hasSeenLastMessage = false; - - // While non-null, the viewport captured at the moment an active manual - // mark-unread (`channel.state.isMarkedAsUnread`) was first observed. - // `_maybeMarkMessagesAsRead` blocks until [_markUnreadViewportDiverged] - // is true — evidence the user did something (scrolled, reopened the - // channel, etc.) since marking the message unread, rather than the - // anchor merely being immediately "visible" again because it's usually - // the very message just marked and nothing has moved. - // - // This can't gate on `isMarkedAsUnread` directly and permanently: that - // flag only clears via a successful mark-read, which is the very thing - // it would be gating, so treating it as a persistent block would - // deadlock the channel unread forever the moment it's set — the exact - // bug this snapshot exists to avoid. - // - // Set eagerly in [_handleCurrentUserReadChanged] right when a live - // transition is observed (captures the precise pre-scroll viewport), and - // in [_handleItemPositionsChanged] on the first genuinely laid-out frame - // as a fallback for when the channel simply mounts with - // `isMarkedAsUnread` already true and no transition ever fires — that - // has to happen there and not lazily inside [_maybeMarkMessagesAsRead], - // since the first time that gate is evaluated might already be the - // user's first genuine arrival at the bottom, which would otherwise be - // burned on capturing the baseline instead of acting on it. Cleared once - // a mark-read actually goes through, or once `isMarkedAsUnread` itself - // clears (so a future mark-unread starts its own fresh snapshot). - // - // Holds visible item *indices* rather than full [ItemPosition]s: comparing - // full positions would latch divergence on a sub-pixel edge change from an - // unrelated relayout (async attachment sizing, keyboard inset, image - // load) even though the user never scrolled, undoing the manual - // mark-unread almost instantly. - List? _markUnreadViewportSnapshot; - - // Sticky once true: sighted the first time [_handleItemPositionsChanged] - // (or, as a fallback, [_maybeMarkMessagesAsRead] itself) sees item - // positions that genuinely differ from [_markUnreadViewportSnapshot]. - // Deliberately tracked as "did this ever happen" rather than - // re-comparing the *current* positions against the snapshot on each - // check — a user who scrolls away and back settles at the exact same - // rest position, which would otherwise look unchanged and re-block a - // mark-read that should already have been earned by that round trip. - bool _markUnreadViewportDiverged = false; - - // State the last mark-read attempt was made against. Item positions tick - // on every scroll frame, so without this a mark-read that keeps failing - // would be retried for as long as the user keeps scrolling at the bottom - // (once a second, as bounded by the debounce). Every input the gate in - // [_maybeMarkMessagesAsRead] actually reads is part of the key, so a - // genuine change — a new message, a mark-unread, the viewport diverging - // after one — still gets its attempt. - ({String? newestMessageId, int unreadCount, bool isMarkedAsUnread, bool viewportDiverged})? _lastMarkReadAttempt; - - // Previous value of `channel.state.isMarkedAsUnread`, so - // [_handleCurrentUserReadChanged] can act on a new mark-unread rather than - // on every read-stream emission that happens while the flag stays set. - // Seeded from the channel on attach, since it can already be set there. - bool _wasMarkedAsUnread = false; - - // Read boundary observed alongside [_wasMarkedAsUnread]. A mark-unread - // moves the boundary backward, so a change here while the flag is already - // set is how a *second* mark-unread is told apart from the read-stream - // emissions that keep arriving during one. - ({DateTime? lastRead, String? lastReadMessageId})? _lastReadBoundary; - - static ({DateTime? lastRead, String? lastReadMessageId})? _readBoundaryOf(Read? read) { - if (read == null) return null; - return (lastRead: read.lastRead, lastReadMessageId: read.lastReadMessageId); - } - - // Whether divider A's current session came from an explicit mark-unread - // rather than from pre-existing unread at channel open. The anchor of a - // manual mark-unread is the message the user was looking at when they - // marked it, so it's already on screen — see - // [_maybeUpdateHasSeenFirstUnread] for why that changes what counts as - // having reached the boundary. - bool _unreadFromManualMarkUnread = false; - - // Captures [_unreadBaseline] the first time the current user's read state - // becomes available, then attempts to resolve divider A's anchor against - // it. No-ops in a thread, where divider A doesn't apply. - void _captureUnreadBaselineIfNeeded() { - if (_unreadBaselineCaptured || _isThreadConversation) return; - - final currentUserRead = streamChannel?.channel.state?.currentUserRead; - if (currentUserRead == null) return; - - _unreadBaselineCaptured = true; - _unreadBaseline = currentUserRead.unreadMessages > 0 ? currentUserRead : null; - // Publish the frozen count right away, even though the anchor itself - // can't resolve until top pagination has loaded that far back — the - // pill only needs the count, not the anchor, so it shouldn't wait on - // pagination to appear (see `_onUnreadPillJumpTap` for how a tap - // before the anchor resolves still jumps there). - if (_unreadBaseline case final baseline?) { - _unreadDivider.value = (count: baseline.unreadMessages, anchorId: _unreadDivider.value.anchorId); - } - _resolveUnreadDivider(); - } - - // Resolves divider A's anchor against the frozen baseline. A no-op once - // resolved, and while top pagination hasn't loaded the boundary yet. - void _resolveUnreadDivider() { - if (_isThreadConversation || _unreadDivider.value.anchorId != null) return; - - final baseline = _unreadBaseline; - if (baseline == null) return; - - final anchor = streamChannel?.getFirstUnreadMessage(baseline); - if (anchor == null) return; - - _unreadDivider.value = (count: baseline.unreadMessages, anchorId: anchor.id); - } - - // Reacts to a `currentUserReadStream` emission. An explicit mark-unread - // moves the read boundary backward — treat it as a new session start for - // divider A/the pill. - // - // The reset is deliberately gated on a *new* mark-unread — the flag - // turning on, or the read boundary moving again while it's already on — - // rather than on the flag merely being set: the read stream also emits - // while it stays set (every new message, for one), and re-running the - // reset then would clear `_hasSeenFirstUnread` again and flicker the pill - // back in and straight out on each arrival. An arriving message bumps - // `unreadMessages` but leaves the boundary untouched, so gating on the - // boundary avoids that flicker while still catching a second mark-unread. - // - // Watching the boundary rather than only the transition matters because - // `isMarkedAsUnread` is cleared solely by a mark-read (see - // `ChannelClientState.markReadLocally`), and both - // [_captureUnreadBaselineIfNeeded] and [_resolveUnreadDivider] freeze once - // resolved — so this reset is the only thing that can move divider A once - // a mark-unread session is under way. - void _handleCurrentUserReadChanged() { - if (_isThreadConversation) return; - - final channel = streamChannel?.channel; - if (channel == null) return; - - final isMarkedAsUnread = channel.state?.isMarkedAsUnread ?? false; - final readBoundary = _readBoundaryOf(channel.state?.currentUserRead); - final boundaryMoved = readBoundary != _lastReadBoundary; - final justMarkedAsUnread = isMarkedAsUnread && (!_wasMarkedAsUnread || boundaryMoved); - _wasMarkedAsUnread = isMarkedAsUnread; - _lastReadBoundary = readBoundary; - - if (justMarkedAsUnread) { - _unreadBaselineCaptured = false; - _unreadBaseline = null; - _unreadDivider.value = (count: 0, anchorId: null); - _unreadDividerGrowth.value = 0; - _hasSeenFirstUnread.value = false; - _unreadFromManualMarkUnread = true; - // Each mark-unread starts its own session, so the snapshot is taken - // fresh here rather than kept from a previous one — but only from a - // viewport that has actually been laid out. `itemPositions` is still - // empty before the first frame, and capturing that would make the - // very first laid-out frame look like divergence, defeating guard 4 - // and leaving the fallback in [_handleItemPositionsChanged] - // unreachable. Left null instead, for that fallback to fill in. - final visibleIndices = _itemPositionListener.itemPositions.value.map((it) => it.index).toList(); - _markUnreadViewportSnapshot = visibleIndices.isEmpty ? null : visibleIndices; - _markUnreadViewportDiverged = false; - } else if (!isMarkedAsUnread) { - _unreadFromManualMarkUnread = false; - _markUnreadViewportSnapshot = null; - _markUnreadViewportDiverged = false; - } - - _captureUnreadBaselineIfNeeded(); - } + // Owns every piece of unread state: the unread messages divider and its + // floating pill, the scroll-to-bottom badge count, and the auto mark-read + // gate. Signals are forwarded to it from the listeners below; the build + // methods render from the listenables it exposes. + late final _unreadController = MessageListUnreadController( + channel: () => streamChannel?.channel, + getFirstUnreadMessage: (read) => streamChannel?.getFirstUnreadMessage(read), + parentMessage: () => widget.parentMessage, + messages: () => messages, + itemPositions: () => _itemPositionListener.itemPositions.value, + markReadWhenAtTheBottom: () => widget.config.markReadWhenAtTheBottom, + scrollToMessage: (id) => _scrollToMessage(messageId: id, highlight: false), + attachToken: () => streamChannel, + ); bool get _upToDate => streamChannel!.channel.state!.isUpToDate; @@ -563,34 +360,14 @@ class _StreamMessageListViewState extends State { if (newStreamChannel != streamChannel) { streamChannel = newStreamChannel; - debouncedMarkRead.cancel(); - debouncedMarkThreadRead.cancel(); - final newChannelState = newStreamChannel.channel.state; - _unreadBaselineCaptured = false; - _unreadBaseline = null; - _unreadDivider.value = (count: 0, anchorId: null); - _unreadDividerGrowth.value = 0; - _scrollToBottomBadge.value = 0; - _hasSeenFirstUnread.value = false; - _hasSeenLastMessage = false; _showScrollToBottom.value = false; - _hasLaidOut.value = false; - _lastMarkReadAttempt = null; - _markUnreadViewportSnapshot = null; - _markUnreadViewportDiverged = false; - // Seeded from the channel's own state rather than hardcoded to false: - // a channel can mount with a manual mark-unread already active (mark - // unread, leave, come back — the flag lives on the cached - // `ChannelClientState`). `currentUserReadStream` is backed by a - // `BehaviorSubject`, so the subscription below replays the current - // value straight away; without this seed that replay would read as a - // brand-new mark-unread and restart a session that never ended. - _wasMarkedAsUnread = newChannelState?.isMarkedAsUnread ?? false; - _lastReadBoundary = _readBoundaryOf(newChannelState?.currentUserRead); - _unreadFromManualMarkUnread = _wasMarkedAsUnread; - _captureUnreadBaselineIfNeeded(); + // Runs after `streamChannel` is reassigned (so the controller resolves + // the new channel) and before the read-stream subscription below, whose + // `BehaviorSubject` replays straight away — the reset seeds the state + // that replay is checked against. + _unreadController.attach(); final highlightInitialMessage = widget.config.highlightInitialMessage; final highlightMessageId = switch ((highlightInitialMessage, _isThreadConversation)) { @@ -621,23 +398,11 @@ class _StreamMessageListViewState extends State { // landing while the user happens to be mid-drag or mid-fling is // exactly what the badge and divider exist to report, and bailing // first would drop it from both counts permanently. - // - // Qualifying arrivals are filtered the same way the channel's own - // unread count filters them, so silent, shadowed, ephemeral, - // thread-only, restricted, muted-sender and own messages don't - // inflate either counter. The badge and divider also only apply to - // the channel's message stream, never to thread replies. - final countsAsUnread = _countsTowardsUnreadIndicators(message, currentUser); - if (!_isThreadConversation && countsAsUnread) { - // The divider counts every qualifying arrival — including ones - // seen live at the bottom — so it keeps counting up like - // WhatsApp's. The badge is narrower: it only exists to flag - // what was missed while scrolled away, so it skips arrivals - // that were already in view and resets once the bottom is - // reached (see `_handleItemPositionsChanged`). - _unreadDividerGrowth.value += 1; - if (!isAtBottom) _scrollToBottomBadge.value += 1; - } + _unreadController.handleMessageArrived( + message, + currentUser: currentUser, + isAtBottom: isAtBottom, + ); // Don't fight a scroll already in motion (drag, fling, or // still-running animated scrollTo). @@ -668,7 +433,7 @@ class _StreamMessageListViewState extends State { _userReadListener?.cancel(); _userReadListener = state?.currentUserReadStream.listen((_) { - _handleCurrentUserReadChanged(); + _unreadController.handleCurrentUserReadChanged(); }); } } @@ -682,14 +447,8 @@ class _StreamMessageListViewState extends State { _userReadListener?.cancel(); _userReadListener = null; _itemPositionListener.itemPositions.removeListener(_handleItemPositionsChanged); - debouncedMarkRead.cancel(); - debouncedMarkThreadRead.cancel(); - _unreadDivider.dispose(); - _unreadDividerGrowth.dispose(); - _hasSeenFirstUnread.dispose(); - _scrollToBottomBadge.dispose(); + _unreadController.dispose(); _showScrollToBottom.dispose(); - _hasLaidOut.dispose(); _highlightState.dispose(); super.dispose(); } @@ -856,9 +615,9 @@ class _StreamMessageListViewState extends State { // settles. Deferred (not synchronous) since mutating a [ValueNotifier] // read by a [ValueListenableBuilder] further down this same build would // notify a listener that hasn't rebuilt yet this frame. - if (_unreadBaseline != null && _unreadDivider.value.anchorId == null) { + if (_unreadController.needsAnchorResolution) { WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) _resolveUnreadDivider(); + if (mounted) _unreadController.resolveDividerAnchor(); }); } @@ -1088,7 +847,7 @@ class _StreamMessageListViewState extends State { Positioned( top: context.streamSpacing.sm, child: ValueListenableBuilder( - valueListenable: _unreadDivider, + valueListenable: _unreadController.unreadDivider, builder: (context, unread, _) { // Gated on the frozen count, not the anchor: the count is // known immediately from the baseline `Read`, while the @@ -1100,7 +859,7 @@ class _StreamMessageListViewState extends State { // scrolled most of the way there themselves. if (unread.count <= 0) return const Empty(); return ValueListenableBuilder( - valueListenable: _hasLaidOut, + valueListenable: _unreadController.hasLaidOut, builder: (context, laidOut, ___) { // Item positions decide whether the boundary is already // on screen, and they only arrive after the first frame @@ -1109,13 +868,13 @@ class _StreamMessageListViewState extends State { // unread message — which is the default. if (!laidOut) return const Empty(); return ValueListenableBuilder( - valueListenable: _hasSeenFirstUnread, + valueListenable: _unreadController.hasSeenFirstUnread, builder: (context, seen, __) { if (seen) return const Empty(); return UnreadIndicatorButton( unreadCount: unread.count, - onJumpTap: (_) => _onUnreadPillJumpTap(), - onDismissTap: _onUnreadPillDismissTap, + onJumpTap: (_) => _unreadController.onPillJumpTapped(), + onDismissTap: _unreadController.onPillDismissTapped, ); }, ); @@ -1180,11 +939,11 @@ class _StreamMessageListViewState extends State { }) { if (_isThreadConversation) return separator; return ValueListenableBuilder( - valueListenable: _unreadDivider, + valueListenable: _unreadController.unreadDivider, builder: (context, unread, _) { if (unread.anchorId != message.id) return separator; return ValueListenableBuilder( - valueListenable: _unreadDividerGrowth, + valueListenable: _unreadController.unreadDividerGrowth, builder: (context, growth, __) => Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, @@ -1215,88 +974,6 @@ class _StreamMessageListViewState extends State { } } - Future _onUnreadPillJumpTap() async { - // The anchor may not have resolved yet if top pagination hasn't loaded - // that far back — the pill is visible already (see its gating above), - // so fall back to the frozen baseline's own last-read boundary, known - // immediately from the server `Read`, rather than doing nothing. - final anchorId = _unreadDivider.value.anchorId ?? _unreadBaseline?.lastReadMessageId; - - // A channel the user has never opened reports unread messages but has - // no read boundary at all: the anchor can't resolve until top - // pagination ends, and there's no `lastReadMessageId` to fall back on - // either. Everything loaded is unread, so head for the oldest message - // currently loaded — as far back as the boundary can be, and it pulls - // the next page in on arrival — rather than leaving the tap inert. - // - // `_hasSeenFirstUnread` is deliberately not latched here: the real - // boundary is further back than where this lands, so the pill stays up - // until it's genuinely reached. - if (anchorId == null) { - final oldestLoaded = messages.lastOrNull; - if (oldestLoaded == null) return; - await _scrollToMessage(messageId: oldestLoaded.id, highlight: false); - return; - } - - // Delegates to [_scrollToMessage], which falls back to - // [StreamChannelState.loadChannelAtMessage] when the anchor isn't in the - // currently loaded window — after which the real anchor resolves - // naturally via the retry in [_buildListView], rendering divider A too. - // `_scrollToMessage` can await pagination and a frame, and this same - // `State` survives a channel change — so remember which channel the tap - // was for and drop the result if it isn't the current one any more. - final tappedFor = streamChannel; - final didJump = await _scrollToMessage(messageId: anchorId, highlight: false); - if (!mounted || streamChannel != tappedFor) return; - // Only claim the boundary as seen once the jump actually landed — - // otherwise (message not found even after pagination, or the SPL not - // attached) the pill would vanish and the mark-read gate would open for - // a boundary the user never actually reached. - if (didJump) _hasSeenFirstUnread.value = true; - } - - Future _onUnreadPillDismissTap() async { - _hasSeenFirstUnread.value = true; - // Dismissing is a local decision; if the request behind it fails there - // is nothing to show the user, and letting it escape here would surface - // as an unhandled async error instead. - _markMessagesAsRead().ignore(); - } - - late final debouncedMarkRead = debounce( - ([String? id]) => streamChannel?.channel.markRead(messageId: id), - const Duration(seconds: 1), - leading: true, - ); - - late final debouncedMarkThreadRead = debounce( - (String parentId) => streamChannel?.channel.markThreadRead(parentId), - const Duration(seconds: 1), - leading: true, - ); - - Future _markMessagesAsRead() async { - if (widget.parentMessage case final parent?) { - // If we are in a thread, mark the thread as read immediately. - await streamChannel?.channel.markThreadRead(parent.id); - return; - } - - // Otherwise, mark the channel as read immediately. - await streamChannel?.channel.markRead(); - } - - Future _debouncedMarkMessagesAsRead() async { - if (widget.parentMessage case final parent?) { - // If we are in a thread, mark the thread as read. - debouncedMarkThreadRead.call([parent.id]); - } else { - // Otherwise, mark the channel as read. - debouncedMarkRead.call(); - } - } - // Determines the applicable [SpacingType]s between two adjacent messages. // // Returns `null` when the messages fall on different days, indicating a @@ -1398,7 +1075,7 @@ class _StreamMessageListViewState extends State { Widget _buildScrollToBottom() { return ValueListenableBuilder( - valueListenable: _scrollToBottomBadge, + valueListenable: _unreadController.scrollToBottomBadge, builder: (_, badgeCount, __) { if (widget.builders.scrollToBottomButton case final builder?) { return builder(badgeCount, scrollToBottomDefaultTapAction); @@ -1527,76 +1204,12 @@ class _StreamMessageListViewState extends State { return _maybeWrapWithHighlight(message: message, child: layout); } - // Whether a freshly-arrived [message] should bump the scroll-to-bottom - // badge and divider A's growing count. - // - // This is the message- and sender-level half of - // [MessageRules.canCountAsUnread], which is what keeps silent, shadowed, - // ephemeral, thread-only, restricted, muted-sender and own messages from - // inflating either counter. - // - // The channel-level half of that rule (`isMuted`, `canUseReadReceipts`, - // `usesLocalUnreadCount`) is deliberately left out. Those govern whether - // the server tracks an unread count for the channel at all, whereas these - // two counters are purely local "what arrived while you weren't looking" - // indicators that should keep working either way — and - // `usesLocalUnreadCount` is an extension getter reading `Channel`'s - // private client field, so it can't be resolved against a channel double - // at all. - // - // The user-level half (`isReadReceiptsEnabled`) is *not* left out: it - // isn't about how the channel is configured but about the user opting out - // of unread tracking entirely, and honouring it here is what keeps these - // indicators from counting up while the channel itself reports zero. - bool _countsTowardsUnreadIndicators(Message message, OwnUser? currentUser) { - if (currentUser == null) return false; - if (!currentUser.isReadReceiptsEnabled) return false; - - if (message.silent) return false; - if (message.shadowed) return false; - if (message.isEphemeral) return false; - - // Thread replies don't count towards the channel's unread state unless - // they were explicitly also sent to the channel. - if (message.parentId != null && message.showInChannel != true) return false; - - final sender = message.user; - if (sender == null) return false; - if (sender.id == currentUser.id) return false; - - if (message.isNotVisibleTo(currentUser.id)) return false; - - final isSenderMuted = currentUser.mutes.any((it) => it.target.id == sender.id); - if (isSenderMuted) return false; - - return true; - } - void _handleItemPositionsChanged() { if (!mounted) return; final itemPositions = _itemPositionListener.itemPositions.value; if (itemPositions.isEmpty) return; - _hasLaidOut.value = true; - - // Snapshot the viewport (or check it against an existing snapshot for - // divergence) the first time it's genuinely laid out while marked as - // unread, in case the channel simply mounted in that state rather than - // [_handleCurrentUserReadChanged] observing a live transition to hook - // the snapshot on. Doing this here — on every non-empty layout, before - // checking anything else below — rather than lazily inside - // [_maybeMarkMessagesAsRead], matters: that gate is only ever evaluated - // when a mark-read could fire, which for a channel the user opens and - // immediately scrolls all the way through might be the very first time - // they reach the bottom. Capturing the baseline there would burn that - // first genuine read on the snapshot itself instead of acting on it. - if (streamChannel?.channel.state?.isMarkedAsUnread ?? false) { - _checkMarkUnreadViewportDivergence(itemPositions); - } - - final justSeenFirstUnread = _maybeUpdateHasSeenFirstUnread(itemPositions); - // Index of the last item in the list view is 2 as 1 is the progress // indicator and 0 is the footer. const lastItemIndex = 2; @@ -1612,184 +1225,10 @@ class _StreamMessageListViewState extends State { } _showScrollToBottom.value = !isLastItemFullyVisible; - if (isLastItemFullyVisible) { - _hasSeenLastMessage = true; - _scrollToBottomBadge.value = 0; - } - - // Attempt a mark-read whenever either half of the gate could have just - // become satisfied; `_maybeMarkMessagesAsRead` does the actual deciding. - if ((isLastItemFullyVisible || justSeenFirstUnread) && widget.config.markReadWhenAtTheBottom) { - _maybeMarkMessagesAsRead().ignore(); - } - } - - // Captures [_markUnreadViewportSnapshot] the first time this is called, - // and otherwise checks [itemPositions] against it, latching - // [_markUnreadViewportDiverged] the first time they genuinely differ. - // Deliberately latching rather than re-comparing *current* positions - // against the snapshot on every check: a user who scrolls away and back - // settles at the exact same rest position, which would otherwise look - // unchanged and re-block a mark-read the round trip should already have - // earned. Safe to call on every position-changed tick — a no-op once - // already diverged. - // - // Compares the set of visible item *indices* rather than full - // [ItemPosition]s (which also carry leading/trailing edge offsets) — an - // unrelated relayout that nudges an edge by a fraction of a pixel isn't - // evidence the user did anything, and shouldn't count as divergence. - void _checkMarkUnreadViewportDivergence(Iterable itemPositions) { - final visibleIndices = itemPositions.map((it) => it.index).toList(); - - if (_markUnreadViewportSnapshot == null) { - _markUnreadViewportSnapshot = visibleIndices; - return; - } - if (_markUnreadViewportDiverged) return; - - const indicesEquality = UnorderedIterableEquality(); - if (!indicesEquality.equals(visibleIndices, _markUnreadViewportSnapshot)) { - _markUnreadViewportDiverged = true; - } - } - - // Marks divider A's anchor as seen once it renders on screen, or once the - // user scrolls past it without it ever rendering (a fast fling can skip - // intermediate frames). Sticky: never reverts once true, and reset only - // when the baseline is recaptured (channel change, or an explicit - // mark-unread — see [_handleCurrentUserReadChanged]). - // - // Sessions started by an explicit mark-unread require scrolling *past* - // the anchor, since it starts out on screen — see - // [_unreadFromManualMarkUnread]. - // - // Returns true iff this call flips [_hasSeenFirstUnread] from false to - // true. - bool _maybeUpdateHasSeenFirstUnread(Iterable itemPositions) { - if (_isThreadConversation || _hasSeenFirstUnread.value) return false; - - final anchorId = _unreadDivider.value.anchorId; - if (anchorId == null) return false; - - final anchorMessageIndex = messages.indexWhere((it) => it.id == anchorId); - if (anchorMessageIndex == -1) return false; - final anchorItemIndex = anchorMessageIndex + 2; - - final visibleIndices = itemPositions.map((position) => position.index).toList(); - if (visibleIndices.isEmpty) return false; - - // Smaller item indices are newer. That is a property of the - // index-to-message mapping (`messages[i - 2]`, newest first), not of the - // scroll direction, so it holds regardless of `config.reverse`. If even - // the newest visible item is older than the anchor, the anchor is no - // longer in view and the user has scrolled back past it into read - // history. - final isScrolledPast = visibleIndices.reduce(min) > anchorItemIndex; - - if (_unreadFromManualMarkUnread) { - // The anchor of a manual mark-unread is the message the user was - // looking at when they marked it, so it's on screen from the outset. - // Counting that sighting would dismiss the pill on the very next - // layout tick — the smallest scroll, or none at all. Only actually - // scrolling past the boundary retires it. - if (!isScrolledPast) return false; - } else if (!visibleIndices.contains(anchorItemIndex) && !isScrolledPast) { - return false; - } - - _hasSeenFirstUnread.value = true; - return true; - } - - // Marks messages as read if the conditions are met. - // - // In a thread: the parent must have at least one reply — the server-side - // thread object doesn't exist until the first reply lands, so - // `markThreadRead` on a reply-less parent 404s. A thread read is - // independent of where the parent channel's own loaded window sits. - // - // In the channel, all of: - // 1. The newest page is loaded (`isUpToDate`). - // 2. There is something unread to mark. - // 3. The bottom has been seen — either it's visible now, or it was - // visible earlier and the user has since scrolled away - // (`hasSeenLastMessage`). - // 4. If there's an active manual mark-unread (`isMarkedAsUnread`), the - // viewport must genuinely differ from the one snapshotted when it - // was first observed (`_markUnreadViewportSnapshot`) — otherwise the - // anchor being immediately "visible" again (it's usually the very - // message just marked, with nothing yet scrolled) would undo the - // user's action instantly. - // 5. Divider A's anchor has actually been seen or scrolled past - // (`hasSeenFirstUnreadMessage`) — trivially satisfied when there is - // no boundary to see in the first place: the channel opened fully - // read, the user has never opened it at all (no `lastReadMessageId`, - // so the anchor could only resolve once top pagination reached the - // very start of the channel — effectively never for real history), - // or the channel uses local unread counts and so has no server read - // state to anchor against. - Future _maybeMarkMessagesAsRead() async { - final channel = streamChannel?.channel; - if (channel == null) return; - - final isInThread = widget.parentMessage != null; - - if (isInThread) { - // A server-side thread object only exists once the parent has at - // least one reply; markThreadRead on a reply-less parent returns 404. - if ((widget.parentMessage?.replyCount ?? 0) == 0) return; - return _debouncedMarkMessagesAsRead(); - } - - final isUpToDate = channel.state?.isUpToDate ?? false; - if (!isUpToDate) return; - - final unreadCount = channel.state?.unreadCount ?? 0; - if (unreadCount <= 0) return; - - // True both when the channel opened fully read (no baseline) and when - // the user has never opened it (a baseline with no `lastReadMessageId`). - // Neither has a boundary the user could reach, so requiring one would - // leave the channel permanently unread — see condition 5 above. - final hasNoUnreadBoundary = _unreadBaselineCaptured && _unreadBaseline?.lastReadMessageId == null; - // Equivalent to `channel.usesLocalUnreadCount`, spelled out via - // `channel.client` rather than `Channel`'s private client field so it - // stays evaluable against a test double that only implements the public - // API surface. - final usesLocalUnreadCount = channel.client.isLocalUnreadCountEnabled && !channel.canUseReadReceipts; - final hasSeenFirstUnreadMessage = hasNoUnreadBoundary || _hasSeenFirstUnread.value || usesLocalUnreadCount; - final isMarkedAsUnread = channel.state?.isMarkedAsUnread ?? false; - final hasSeenLastMessage = _hasSeenLastMessage || !_showScrollToBottom.value; - - if (!hasSeenLastMessage) return; - if (!hasSeenFirstUnreadMessage) return; - - if (isMarkedAsUnread) { - // `_handleItemPositionsChanged` already keeps this up to date on - // every position-changed tick; this call only matters as a fallback - // if this is ever reached some other way. See - // `_markUnreadViewportSnapshot`'s doc comment for why the guard has - // to latch on divergence rather than checking `isMarkedAsUnread` - // directly as a persistent gate. - _checkMarkUnreadViewportDivergence(_itemPositionListener.itemPositions.value); - if (!_markUnreadViewportDiverged) return; - } - - // Everything the gate above reads is in the key, so an attempt is only - // skipped when repeating it could not produce a different outcome. - final attempt = ( - newestMessageId: messages.firstOrNull?.id, - unreadCount: unreadCount, - isMarkedAsUnread: isMarkedAsUnread, - viewportDiverged: _markUnreadViewportDiverged, + _unreadController.handleItemPositionsChanged( + itemPositions, + isAtBottom: isLastItemFullyVisible, ); - if (attempt == _lastMarkReadAttempt) return; - _lastMarkReadAttempt = attempt; - - await _debouncedMarkMessagesAsRead(); - _hasSeenLastMessage = false; - _markUnreadViewportSnapshot = null; - _markUnreadViewportDiverged = false; } void _getOnThreadTap() { diff --git a/packages/stream_chat_flutter/test/src/message_list_view/message_list_unread_controller_test.dart b/packages/stream_chat_flutter/test/src/message_list_view/message_list_unread_controller_test.dart new file mode 100644 index 000000000..57468260e --- /dev/null +++ b/packages/stream_chat_flutter/test/src/message_list_view/message_list_unread_controller_test.dart @@ -0,0 +1,650 @@ +// Unit tests for [MessageListUnreadController], the unread state machine +// behind StreamMessageListView. +// +// The widget-level behaviour is already locked by mark_read_test.dart and +// unread_divider_test.dart; these tests target the branches that are awkward +// to reach through a pumped widget — the arrival filter matrix, each +// individual condition of the mark-read gate, the attempt-dedupe key, the +// mark-unread viewport divergence latch, and the pill's jump fallbacks. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat_flutter/scrollable_positioned_list/scrollable_positioned_list.dart'; +import 'package:stream_chat_flutter/src/message_list_view/message_list_unread_controller.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import '../mocks.dart'; + +void main() { + late MockClient client; + late MockChannel channel; + late MockChannelState channelState; + late OwnUser ownUser; + + // Mutable fixture the controller reads through its injected accessors. + late List messages; + late Message? parentMessage; + late List itemPositions; + late bool markReadWhenAtTheBottom; + late Message? firstUnreadMessage; + late Object? attachToken; + + // Records every scroll the controller asks for, and controls whether the + // scroll is reported as having landed. + late List scrollRequests; + late bool scrollLands; + + MessageListUnreadController buildController() { + final controller = MessageListUnreadController( + channel: () => channel, + getFirstUnreadMessage: (_) => firstUnreadMessage, + parentMessage: () => parentMessage, + messages: () => messages, + itemPositions: () => itemPositions, + markReadWhenAtTheBottom: () => markReadWhenAtTheBottom, + scrollToMessage: (id) async { + scrollRequests.add(id); + return scrollLands; + }, + attachToken: () => attachToken, + ); + addTearDown(controller.dispose); + return controller; + } + + Message message({ + String id = 'msg-1', + User? user, + bool silent = false, + bool shadowed = false, + String? parentId, + bool? showInChannel, + String? type, + List? restrictedVisibility, + }) { + return Message( + id: id, + text: 'hello', + user: user ?? User(id: 'other'), + silent: silent, + shadowed: shadowed, + parentId: parentId, + showInChannel: showInChannel, + type: type ?? MessageType.regular, + restrictedVisibility: restrictedVisibility, + ); + } + + // A viewport showing the given item indices, all fully visible. + List viewport(List indices) { + return [ + for (final index in indices) ItemPosition(index: index, itemLeadingEdge: 0.1, itemTrailingEdge: 0.2), + ]; + } + + // Delivers a positions tick, keeping the injected `itemPositions` in step + // with what is handed to the controller — the list reads both from the same + // listener, so a test that only passed one would let the gate's fallback + // divergence check compare against a viewport that never existed. + void tick( + MessageListUnreadController controller, + List indices, { + required bool isAtBottom, + }) { + itemPositions = viewport(indices); + controller.handleItemPositionsChanged(itemPositions, isAtBottom: isAtBottom); + } + + Read read({ + DateTime? lastRead, + String? lastReadMessageId, + int unreadMessages = 0, + }) { + return Read( + user: ownUser, + lastRead: lastRead ?? DateTime.utc(2024), + lastReadMessageId: lastReadMessageId, + unreadMessages: unreadMessages, + ); + } + + setUp(() { + client = MockClient(); + // `canUseReadReceipts` is an extension getter over `ownCapabilities`, so + // it is granted through the capability rather than stubbed. + channel = MockChannel(ownCapabilities: const [ChannelCapability.readEvents]); + channelState = MockChannelState(); + ownUser = OwnUser(id: 'ownid'); + + when(() => channel.client).thenReturn(client); + when(() => channel.state).thenReturn(channelState); + when(() => client.isLocalUnreadCountEnabled).thenReturn(false); + when(() => channel.markRead()).thenAnswer((_) async => EmptyResponse()); + when(() => channel.markRead(messageId: any(named: 'messageId'))).thenAnswer((_) async => EmptyResponse()); + when(() => channel.markThreadRead(any())).thenAnswer((_) async => EmptyResponse()); + when(() => channelState.currentUserRead).thenReturn(null); + + messages = []; + parentMessage = null; + itemPositions = []; + markReadWhenAtTheBottom = true; + firstUnreadMessage = null; + attachToken = 'channel-1'; + scrollRequests = []; + scrollLands = true; + }); + + group('message arrivals', () { + test('a qualifying arrival bumps the divider growth and the badge', () { + final controller = buildController()..handleMessageArrived(message(), currentUser: ownUser, isAtBottom: false); + + expect(controller.unreadDividerGrowth.value, 1); + expect(controller.scrollToBottomBadge.value, 1); + }); + + test('an arrival seen at the bottom counts for the divider but not the badge', () { + final controller = buildController()..handleMessageArrived(message(), currentUser: ownUser, isAtBottom: true); + + expect(controller.unreadDividerGrowth.value, 1); + expect(controller.scrollToBottomBadge.value, 0); + }); + + test('nothing counts in a thread', () { + parentMessage = message(id: 'parent'); + final controller = buildController()..handleMessageArrived(message(), currentUser: ownUser, isAtBottom: false); + + expect(controller.unreadDividerGrowth.value, 0); + expect(controller.scrollToBottomBadge.value, 0); + }); + + test('nothing counts without a current user', () { + final controller = buildController()..handleMessageArrived(message(), currentUser: null, isAtBottom: false); + + expect(controller.unreadDividerGrowth.value, 0); + expect(controller.scrollToBottomBadge.value, 0); + }); + + test('nothing counts while the user has read receipts disabled', () { + final controller = buildController(); + final optedOut = OwnUser( + id: 'ownid', + privacySettings: const PrivacySettings(readReceipts: ReadReceipts(enabled: false)), + ); + + controller.handleMessageArrived(message(), currentUser: optedOut, isAtBottom: false); + + expect(controller.unreadDividerGrowth.value, 0); + expect(controller.scrollToBottomBadge.value, 0); + }); + + test("the current user's own messages do not count", () { + final controller = buildController() + ..handleMessageArrived(message(user: ownUser), currentUser: ownUser, isAtBottom: false); + + expect(controller.unreadDividerGrowth.value, 0); + }); + + test('a message from a muted user does not count', () { + final controller = buildController(); + final muter = OwnUser( + id: 'ownid', + mutes: [ + Mute( + user: ownUser, + target: User(id: 'other'), + createdAt: DateTime.utc(2024), + updatedAt: DateTime.utc(2024), + ), + ], + ); + + controller.handleMessageArrived(message(), currentUser: muter, isAtBottom: false); + + expect(controller.unreadDividerGrowth.value, 0); + }); + + test('silent, shadowed and ephemeral messages do not count', () { + final controller = buildController() + ..handleMessageArrived(message(id: 'a', silent: true), currentUser: ownUser, isAtBottom: false) + ..handleMessageArrived(message(id: 'b', shadowed: true), currentUser: ownUser, isAtBottom: false) + ..handleMessageArrived( + message(id: 'c', type: MessageType.ephemeral), + currentUser: ownUser, + isAtBottom: false, + ); + + expect(controller.unreadDividerGrowth.value, 0); + }); + + test('a thread reply not also sent to the channel does not count', () { + final controller = buildController() + ..handleMessageArrived( + message(parentId: 'parent'), + currentUser: ownUser, + isAtBottom: false, + ); + + expect(controller.unreadDividerGrowth.value, 0); + }); + + test('a thread reply also sent to the channel counts', () { + final controller = buildController() + ..handleMessageArrived( + message(parentId: 'parent', showInChannel: true), + currentUser: ownUser, + isAtBottom: false, + ); + + expect(controller.unreadDividerGrowth.value, 1); + }); + + test('a message restricted to other users does not count', () { + final controller = buildController() + ..handleMessageArrived( + message(restrictedVisibility: const ['someone-else']), + currentUser: ownUser, + isAtBottom: false, + ); + + expect(controller.unreadDividerGrowth.value, 0); + }); + }); + + group('badge reset', () { + test('reaching the bottom clears the badge but keeps the divider growth', () { + markReadWhenAtTheBottom = false; + final controller = buildController()..handleMessageArrived(message(), currentUser: ownUser, isAtBottom: false); + + tick(controller, [2, 3], isAtBottom: true); + + expect(controller.scrollToBottomBadge.value, 0); + expect(controller.unreadDividerGrowth.value, 1); + }); + + test('a tick away from the bottom leaves the badge alone', () { + markReadWhenAtTheBottom = false; + final controller = buildController()..handleMessageArrived(message(), currentUser: ownUser, isAtBottom: false); + + tick(controller, [8, 9], isAtBottom: false); + + expect(controller.scrollToBottomBadge.value, 1); + }); + }); + + group('empty positions ticks', () { + test('an empty viewport does not count as laid out', () { + final controller = buildController(); + + tick(controller, [], isAtBottom: false); + + expect(controller.hasLaidOut.value, isFalse); + }); + + test('an empty viewport cannot satisfy the mark-unread divergence guard', () { + // Without the guard the empty tick would snapshot an empty index set, + // which the first real frame would then read as divergence and use to + // undo the manual mark-unread. + when(() => channelState.isMarkedAsUnread).thenReturn(true); + when(() => channelState.unreadCount).thenReturn(3); + when(() => channelState.currentUserRead).thenReturn(read(unreadMessages: 3)); + messages = [message(id: 'newest')]; + final controller = buildController()..attach(); + + tick(controller, [], isAtBottom: true); + tick(controller, [2, 3], isAtBottom: true); + + verifyNever(() => channel.markRead()); + }); + }); + + group('baseline capture', () { + test('publishes the frozen count before the anchor resolves', () { + when(() => channelState.currentUserRead).thenReturn(read(unreadMessages: 5, lastReadMessageId: 'm-5')); + firstUnreadMessage = null; // pagination hasn't reached the boundary yet + final controller = buildController()..attach(); + + expect(controller.unreadDivider.value.count, 5); + expect(controller.unreadDivider.value.anchorId, isNull); + expect(controller.needsAnchorResolution, isTrue); + }); + + test('resolveDividerAnchor fills in the anchor once the boundary loads', () { + when(() => channelState.currentUserRead).thenReturn(read(unreadMessages: 5, lastReadMessageId: 'm-5')); + final controller = buildController()..attach(); + + firstUnreadMessage = message(id: 'm-6'); + controller.resolveDividerAnchor(); + + expect(controller.unreadDivider.value, (count: 5, anchorId: 'm-6')); + expect(controller.needsAnchorResolution, isFalse); + }); + + test('the anchor is frozen once resolved', () { + when(() => channelState.currentUserRead).thenReturn(read(unreadMessages: 5, lastReadMessageId: 'm-5')); + firstUnreadMessage = message(id: 'm-6'); + final controller = buildController()..attach(); + + firstUnreadMessage = message(id: 'm-99'); + controller.resolveDividerAnchor(); + + expect(controller.unreadDivider.value.anchorId, 'm-6'); + }); + + test('a channel opened fully read publishes no divider', () { + when(() => channelState.currentUserRead).thenReturn(read(unreadMessages: 0, lastReadMessageId: 'm-9')); + final controller = buildController()..attach(); + + expect(controller.unreadDivider.value, (count: 0, anchorId: null)); + expect(controller.needsAnchorResolution, isFalse); + }); + + test('no baseline is captured in a thread', () { + parentMessage = message(id: 'parent'); + when(() => channelState.currentUserRead).thenReturn(read(unreadMessages: 5, lastReadMessageId: 'm-5')); + final controller = buildController()..attach(); + + expect(controller.unreadDivider.value, (count: 0, anchorId: null)); + }); + }); + + group('read state changes', () { + test('a fresh mark-unread restarts the divider session', () { + when(() => channelState.currentUserRead).thenReturn(read(unreadMessages: 2, lastReadMessageId: 'm-8')); + firstUnreadMessage = message(id: 'm-9'); + final controller = buildController() + ..attach() + ..handleMessageArrived(message(), currentUser: ownUser, isAtBottom: false); + expect(controller.unreadDividerGrowth.value, 1); + + // The user marks an older message unread: the flag flips on and the + // boundary moves backward. + when(() => channelState.isMarkedAsUnread).thenReturn(true); + when(() => channelState.currentUserRead).thenReturn(read(unreadMessages: 6, lastReadMessageId: 'm-4')); + firstUnreadMessage = message(id: 'm-5'); + controller.handleCurrentUserReadChanged(); + + expect(controller.unreadDivider.value, (count: 6, anchorId: 'm-5')); + expect(controller.unreadDividerGrowth.value, 0); + expect(controller.hasSeenFirstUnread.value, isFalse); + }); + + test('an emission that leaves the boundary alone does not restart the session', () { + when(() => channelState.isMarkedAsUnread).thenReturn(true); + when(() => channelState.currentUserRead).thenReturn(read(unreadMessages: 3, lastReadMessageId: 'm-4')); + firstUnreadMessage = message(id: 'm-5'); + final controller = buildController()..attach(); + // The session came from a manual mark-unread, so only scrolling *past* + // the anchor retires the pill: the anchor sits at item index 2, so a + // viewport showing only older items (higher indices) is past it. + messages = [message(id: 'm-5'), message(id: 'm-4')]; + tick(controller, [3], isAtBottom: false); + expect(controller.hasSeenFirstUnread.value, isTrue); + + // A new message arrives: unreadMessages grows, the boundary does not move. + when(() => channelState.currentUserRead).thenReturn(read(unreadMessages: 4, lastReadMessageId: 'm-4')); + controller.handleCurrentUserReadChanged(); + + expect(controller.hasSeenFirstUnread.value, isTrue, reason: 'the pill must not flicker back in'); + }); + + test('a second mark-unread further back re-anchors the divider', () { + when(() => channelState.isMarkedAsUnread).thenReturn(true); + when(() => channelState.currentUserRead).thenReturn(read(unreadMessages: 3, lastReadMessageId: 'm-6')); + firstUnreadMessage = message(id: 'm-7'); + final controller = buildController()..attach(); + + when(() => channelState.currentUserRead).thenReturn(read(unreadMessages: 7, lastReadMessageId: 'm-2')); + firstUnreadMessage = message(id: 'm-3'); + controller.handleCurrentUserReadChanged(); + + expect(controller.unreadDivider.value, (count: 7, anchorId: 'm-3')); + }); + }); + + group('mark-read gate', () { + test('marks read once the bottom is reached with something unread', () { + when(() => channelState.unreadCount).thenReturn(3); + when(() => channelState.currentUserRead).thenReturn(read(unreadMessages: 3)); + messages = [message(id: 'newest')]; + final controller = buildController()..attach(); + + tick(controller, [2], isAtBottom: true); + + verify(() => channel.markRead()).called(1); + }); + + test('does not mark read while the channel is not up to date', () { + when(() => channelState.isUpToDate).thenReturn(false); + when(() => channelState.unreadCount).thenReturn(3); + when(() => channelState.currentUserRead).thenReturn(read(unreadMessages: 3)); + final controller = buildController()..attach(); + + tick(controller, [2], isAtBottom: true); + + verifyNever(() => channel.markRead()); + }); + + test('does not mark read when there is nothing unread', () { + when(() => channelState.unreadCount).thenReturn(0); + final controller = buildController()..attach(); + + tick(controller, [2], isAtBottom: true); + + verifyNever(() => channel.markRead()); + }); + + test('does not mark read while the bottom has never been seen', () { + when(() => channelState.unreadCount).thenReturn(3); + when(() => channelState.currentUserRead).thenReturn(read(unreadMessages: 3)); + firstUnreadMessage = message(id: 'm-5'); + messages = [message(id: 'm-5')]; + final controller = buildController()..attach(); + + // Seeing the boundary opens condition 5, but the bottom is still away. + tick(controller, [2], isAtBottom: false); + + expect(controller.hasSeenFirstUnread.value, isTrue); + verifyNever(() => channel.markRead()); + }); + + test('does not mark read while the unread boundary has not been seen', () { + when(() => channelState.unreadCount).thenReturn(3); + when(() => channelState.currentUserRead).thenReturn(read(unreadMessages: 3, lastReadMessageId: 'm-4')); + firstUnreadMessage = message(id: 'm-5'); + messages = [message(id: 'newest'), message(id: 'm-5')]; + final controller = buildController()..attach(); + + // At the bottom, but the anchor (item index 3) is out of view. + tick(controller, [2], isAtBottom: true); + + verifyNever(() => channel.markRead()); + }); + + test('marks read on a never-opened channel that has no boundary to reach', () { + when(() => channelState.unreadCount).thenReturn(3); + when(() => channelState.currentUserRead).thenReturn(read(unreadMessages: 3)); + final controller = buildController()..attach(); + + tick(controller, [2], isAtBottom: true); + + verify(() => channel.markRead()).called(1); + }); + + test('does nothing while markReadWhenAtTheBottom is off', () { + markReadWhenAtTheBottom = false; + when(() => channelState.unreadCount).thenReturn(3); + when(() => channelState.currentUserRead).thenReturn(read(unreadMessages: 3)); + final controller = buildController()..attach(); + + tick(controller, [2], isAtBottom: true); + + verifyNever(() => channel.markRead()); + }); + + test('a repeated tick against unchanged state does not retry the attempt', () { + when(() => channelState.unreadCount).thenReturn(3); + when(() => channelState.currentUserRead).thenReturn(read(unreadMessages: 3)); + messages = [message(id: 'newest')]; + final controller = buildController()..attach(); + + tick(controller, [2], isAtBottom: true); + tick(controller, [2], isAtBottom: true); + tick(controller, [2], isAtBottom: true); + + verify(() => channel.markRead()).called(1); + }); + + test('a new newest message earns a fresh attempt', () async { + when(() => channelState.unreadCount).thenReturn(3); + when(() => channelState.currentUserRead).thenReturn(read(unreadMessages: 3)); + messages = [message(id: 'newest')]; + final controller = buildController()..attach(); + tick(controller, [2], isAtBottom: true); + + // The debounce is leading-edge, so let its window lapse before the + // second attempt, which is otherwise swallowed by the debouncer + // rather than by the dedupe key under test. + await Future.delayed(const Duration(milliseconds: 1100)); + messages = [message(id: 'even-newer'), message(id: 'newest')]; + tick(controller, [2], isAtBottom: true); + + verify(() => channel.markRead()).called(2); + }); + + group('with an active manual mark-unread', () { + setUp(() { + when(() => channelState.isMarkedAsUnread).thenReturn(true); + when(() => channelState.unreadCount).thenReturn(3); + when(() => channelState.currentUserRead).thenReturn(read(unreadMessages: 3)); + messages = [message(id: 'newest')]; + }); + + test('does not mark read while the viewport has not moved', () { + final controller = buildController()..attach(); + + tick(controller, [2, 3], isAtBottom: true); + tick(controller, [2, 3], isAtBottom: true); + + verifyNever(() => channel.markRead()); + }); + + test('marks read once the viewport genuinely differs', () { + final controller = buildController()..attach(); + + tick(controller, [2, 3], isAtBottom: false); + tick(controller, [4, 5], isAtBottom: true); + + verify(() => channel.markRead()).called(1); + }); + + test('divergence latches, so returning to the same rest position still marks read', () { + final controller = buildController()..attach(); + + tick(controller, [2, 3], isAtBottom: true); + tick(controller, [6, 7], isAtBottom: false); + tick(controller, [2, 3], isAtBottom: true); + + verify(() => channel.markRead()).called(1); + }); + }); + + group('in a thread', () { + test('marks the thread read once the parent has replies', () { + parentMessage = Message(id: 'parent', text: 'p', replyCount: 2); + final controller = buildController()..attach(); + + tick(controller, [2], isAtBottom: true); + + verify(() => channel.markThreadRead('parent')).called(1); + }); + + test('does not mark a reply-less parent read', () { + parentMessage = Message(id: 'parent', text: 'p', replyCount: 0); + final controller = buildController()..attach(); + + tick(controller, [2], isAtBottom: true); + + verifyNever(() => channel.markThreadRead(any())); + }); + }); + }); + + group('pill taps', () { + test('jump scrolls to the resolved anchor and retires the pill', () async { + when(() => channelState.currentUserRead).thenReturn(read(unreadMessages: 4, lastReadMessageId: 'm-4')); + firstUnreadMessage = message(id: 'm-5'); + final controller = buildController()..attach(); + + await controller.onPillJumpTapped(); + + expect(scrollRequests, ['m-5']); + expect(controller.hasSeenFirstUnread.value, isTrue); + }); + + test('jump falls back to the baseline boundary when the anchor is unresolved', () async { + when(() => channelState.currentUserRead).thenReturn(read(unreadMessages: 4, lastReadMessageId: 'm-4')); + firstUnreadMessage = null; + final controller = buildController()..attach(); + + await controller.onPillJumpTapped(); + + expect(scrollRequests, ['m-4']); + }); + + test('jump heads for the oldest loaded message when there is no boundary at all', () async { + when(() => channelState.currentUserRead).thenReturn(read(unreadMessages: 4)); + messages = [message(id: 'newest'), message(id: 'oldest')]; + final controller = buildController()..attach(); + + await controller.onPillJumpTapped(); + + expect(scrollRequests, ['oldest']); + expect( + controller.hasSeenFirstUnread.value, + isFalse, + reason: 'the real boundary is further back than this lands', + ); + }); + + test('a jump that never landed leaves the pill up', () async { + scrollLands = false; + when(() => channelState.currentUserRead).thenReturn(read(unreadMessages: 4, lastReadMessageId: 'm-4')); + firstUnreadMessage = message(id: 'm-5'); + final controller = buildController()..attach(); + + await controller.onPillJumpTapped(); + + expect(controller.hasSeenFirstUnread.value, isFalse); + }); + + test('a jump result arriving after a channel change is dropped', () async { + when(() => channelState.currentUserRead).thenReturn(read(unreadMessages: 4, lastReadMessageId: 'm-4')); + firstUnreadMessage = message(id: 'm-5'); + final controller = buildController()..attach(); + + final jump = controller.onPillJumpTapped(); + attachToken = 'channel-2'; + await jump; + + expect(controller.hasSeenFirstUnread.value, isFalse); + }); + + test('dismiss retires the pill and marks the channel read immediately', () async { + final controller = buildController(); + + await controller.onPillDismissTapped(); + + expect(controller.hasSeenFirstUnread.value, isTrue); + verify(() => channel.markRead()).called(1); + }); + + test('dismiss in a thread marks the thread read', () async { + parentMessage = message(id: 'parent'); + final controller = buildController(); + + await controller.onPillDismissTapped(); + + verify(() => channel.markThreadRead('parent')).called(1); + }); + }); +} diff --git a/packages/stream_chat_flutter/test/src/message_list_view/unread_divider_test.dart b/packages/stream_chat_flutter/test/src/message_list_view/unread_divider_test.dart index b540dd42b..095847065 100644 --- a/packages/stream_chat_flutter/test/src/message_list_view/unread_divider_test.dart +++ b/packages/stream_chat_flutter/test/src/message_list_view/unread_divider_test.dart @@ -5,8 +5,8 @@ // pre-existing unread boundary captured when the channel opens. The // anchor is frozen — it stays on screen for the whole session regardless // of scrolling or reads — but its displayed count keeps counting up as -// further messages arrive out of view during the session, mirroring -// WhatsApp, rather than staying frozen at the open-time count. +// further messages arrive out of view during the session, rather than +// staying frozen at the open-time count. // - The pill shows the count of unread messages captured when the channel // was opened — this one *does* stay frozen — and is gated on that // boundary being above the viewport.