@@ -5,6 +5,7 @@ import 'dart:math';
55import 'package:collection/collection.dart' ;
66import 'package:flutter/material.dart' ;
77import 'package:flutter_portal/flutter_portal.dart' ;
8+ import 'package:rxdart/rxdart.dart' ;
89import 'package:stream_chat_flutter/scrollable_positioned_list/scrollable_positioned_list.dart' ;
910import 'package:stream_chat_flutter/src/message_list_view/floating_date_divider.dart' ;
1011import 'package:stream_chat_flutter/src/message_list_view/loading_indicator.dart' ;
@@ -416,7 +417,7 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
416417 MessageListController get _messageListController =>
417418 widget.messageListController ?? _defaultController;
418419
419- StreamSubscription ? _messageNewListener;
420+ StreamSubscription < Message > ? _messageNewListener;
420421 StreamSubscription ? _userReadListener;
421422
422423 @override
@@ -444,43 +445,46 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
444445 debouncedMarkRead.cancel ();
445446 debouncedMarkThreadRead.cancel ();
446447
447- _messageNewListener? .cancel ();
448448 _userReadListener? .cancel ();
449+ _messageNewListener? .cancel ();
449450
450451 _unreadState.value = _readUnreadSnapshot ();
451452
452- _messageNewListener =
453- streamChannel! .channel.on (EventType .messageNew).listen ((event) {
454- final message = event.message;
455- if (message == null ) return ;
456- if (message.parentId != widget.parentMessage? .id) return ;
457-
453+ // `newMessageStream` fires on every path that grows the bottom
454+ // of the list — server-confirmed `message.new` events AND
455+ // optimistic local sends — so we follow to the new bottom for
456+ // both. Gated on `isUpToDate` to match Android/iOS/RN: no
457+ // auto-scroll while the channel is loaded around a historic id.
458+ final state = streamChannel! .channel.state! ;
459+ final newMessageStream = switch (widget.parentMessage? .id) {
460+ final parentId? => state.newThreadMessageStream (parentId),
461+ _ => state.newMessageStream,
462+ };
463+ _messageNewListener = newMessageStream.listen ((newMessage) {
458464 // Don't fight a scroll already in motion (drag, fling, or
459465 // still-running animated scrollTo).
460466 if (_scrollController? .isScrolling == true ) return ;
461467
462468 final currentUser = streamChannel? .channel.client.state.currentUser;
463- final isOwnMessage = message .user? .id == currentUser? .id;
469+ final isOwnMessage = newMessage .user? .id == currentUser? .id;
464470 final isAtBottom = ! _showScrollToBottom.value;
465471
466472 // Auto-scroll on own messages always; on others only when the
467473 // user is already at the bottom. For "far from bottom", SPL's
468- // itemKeyBuilder anchor preservation keeps the visible
469- // content pinned.
474+ // itemKeyBuilder anchor preservation keeps the visible content
475+ // pinned.
470476 if (! isOwnMessage && ! isAtBottom) return ;
471477
472- // Synchronous (not post-frame) so SPL's `_scrollTo` clears
473- // its anchor key before the next `didUpdateWidget` — otherwise
474- // anchor preservation would yank the layout back and produce
475- // a visible "shift, then animate" glitch.
476- if (_scrollController case final controller?
477- when controller.isAttached) {
478- controller.scrollTo (index: 0 );
478+ // Synchronous (not post-frame) so SPL's `_scrollTo` clears its
479+ // anchor key before the next `didUpdateWidget` — otherwise
480+ // anchor preservation would yank the layout back and produce a
481+ // visible "shift, then animate" glitch.
482+ if (_scrollController case final c? when c.isAttached) {
483+ c.scrollTo (index: 0 );
479484 }
480485 });
481486
482- _userReadListener =
483- streamChannel! .channel.state? .currentUserReadStream.listen ((_) {
487+ _userReadListener = state.currentUserReadStream.listen ((_) {
484488 _unreadState.value = _readUnreadSnapshot ();
485489 });
486490 }
@@ -1508,3 +1512,76 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
15081512 }
15091513 }
15101514}
1515+
1516+ extension on ChannelClientState {
1517+ /// Emits each message appended to the bottom of [messages] —
1518+ /// regardless of source: server-confirmed `message.new` events,
1519+ /// optimistic local sends, or any other path that grows the list.
1520+ ///
1521+ /// Detection: the bottom-most message id changed AND its `createdAt`
1522+ /// is strictly after the previously-seen tail. The `createdAt` check
1523+ /// guards against firing on deletion-of-tail (the new tail is older)
1524+ /// and pruning-only emissions.
1525+ ///
1526+ /// Gated on [isUpToDate] (matching Android's `areNewestMessagesLoaded`
1527+ /// and iOS's `isFirstPageLoaded` ): while the channel is loaded around
1528+ /// a historic message, no emissions — composers/UI flows are
1529+ /// expected to load the live tail before triggering a send. The
1530+ /// `false → true` flip itself is a wholesale window replacement,
1531+ /// also suppressed (the next emit re-seeds).
1532+ Stream <Message > get newMessageStream async * {
1533+ var wasUpToDate = isUpToDate;
1534+ var lastSeen = wasUpToDate ? messages.lastOrNull : null ;
1535+
1536+ await for (final emitted in messagesStream) {
1537+ if (! isUpToDate) {
1538+ wasUpToDate = false ;
1539+ lastSeen = null ;
1540+ continue ;
1541+ }
1542+ if (! wasUpToDate) {
1543+ wasUpToDate = true ;
1544+ lastSeen = emitted.lastOrNull;
1545+ continue ;
1546+ }
1547+ final newLast = emitted.lastOrNull;
1548+ if (newLast == null ) {
1549+ lastSeen = null ;
1550+ continue ;
1551+ }
1552+ final isNewArrival = lastSeen == null ||
1553+ (newLast.id != lastSeen.id &&
1554+ newLast.createdAt.isAfter (lastSeen.createdAt));
1555+ lastSeen = newLast;
1556+ if (isNewArrival) yield newLast;
1557+ }
1558+ }
1559+
1560+ /// Same as [newMessageStream] , scoped to a single thread. The first
1561+ /// emission carrying this thread's messages is treated as a seed
1562+ /// (the just-loaded page) and not surfaced as a "new message."
1563+ Stream <Message > newThreadMessageStream (String parentMessageId) async * {
1564+ final threadMessagesStream =
1565+ threadsStream.mapNotNull ((threads) => threads[parentMessageId]);
1566+
1567+ Message ? lastSeen;
1568+ var seeded = false ;
1569+ await for (final messages in threadMessagesStream) {
1570+ final newLast = messages.lastOrNull;
1571+ if (! seeded) {
1572+ seeded = true ;
1573+ lastSeen = newLast;
1574+ continue ;
1575+ }
1576+ if (newLast == null ) {
1577+ lastSeen = null ;
1578+ continue ;
1579+ }
1580+ final isNewArrival = lastSeen == null ||
1581+ (newLast.id != lastSeen.id &&
1582+ newLast.createdAt.isAfter (lastSeen.createdAt));
1583+ lastSeen = newLast;
1584+ if (isNewArrival) yield newLast;
1585+ }
1586+ }
1587+ }
0 commit comments