@@ -416,7 +416,7 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
416416 MessageListController get _messageListController =>
417417 widget.messageListController ?? _defaultController;
418418
419- StreamSubscription ? _messageNewListener;
419+ StreamSubscription < Message > ? _messageNewListener;
420420 StreamSubscription ? _userReadListener;
421421
422422 @override
@@ -444,40 +444,11 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
444444 debouncedMarkRead.cancel ();
445445 debouncedMarkThreadRead.cancel ();
446446
447- _messageNewListener? .cancel ();
448447 _userReadListener? .cancel ();
449448
450449 _unreadState.value = _readUnreadSnapshot ();
451450
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-
458- // Don't fight a scroll already in motion (drag, fling, or
459- // still-running animated scrollTo).
460- if (_scrollController? .isScrolling == true ) return ;
461-
462- final currentUser = streamChannel? .channel.client.state.currentUser;
463- final isOwnMessage = message.user? .id == currentUser? .id;
464- final isAtBottom = ! _showScrollToBottom.value;
465-
466- // Auto-scroll on own messages always; on others only when the
467- // user is already at the bottom. For "far from bottom", SPL's
468- // itemKeyBuilder anchor preservation keeps the visible
469- // content pinned.
470- if (! isOwnMessage && ! isAtBottom) return ;
471-
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 );
479- }
480- });
451+ _subscribeMessageNewListener ();
481452
482453 _userReadListener =
483454 streamChannel! .channel.state? .currentUserReadStream.listen ((_) {
@@ -486,6 +457,54 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
486457 }
487458 }
488459
460+ @override
461+ void didUpdateWidget (StreamMessageListView oldWidget) {
462+ super .didUpdateWidget (oldWidget);
463+ // Thread parent change on the same channel doesn't trigger
464+ // `didChangeDependencies`, so re-bind here.
465+ if (oldWidget.parentMessage? .id != widget.parentMessage? .id) {
466+ _subscribeMessageNewListener ();
467+ }
468+ }
469+
470+ // `newMessageStream` fires on every path that grows the bottom of
471+ // the list — server-confirmed `message.new` events AND optimistic
472+ // local sends — so we follow to the new bottom for both. Gated on
473+ // `isUpToDate` to match Android/iOS/RN: no auto-scroll while the
474+ // channel is loaded around a historic id.
475+ void _subscribeMessageNewListener () {
476+ _messageNewListener? .cancel ();
477+ final state = streamChannel? .channel.state;
478+ if (state == null ) return ;
479+ final newMessageStream = switch (widget.parentMessage? .id) {
480+ final parentId? => state.newThreadMessageStream (parentId),
481+ _ => state.newMessageStream,
482+ };
483+ _messageNewListener = newMessageStream.listen ((newMessage) {
484+ // Don't fight a scroll already in motion (drag, fling, or
485+ // still-running animated scrollTo).
486+ if (_scrollController? .isScrolling == true ) return ;
487+
488+ final currentUser = streamChannel? .channel.client.state.currentUser;
489+ final isOwnMessage = newMessage.user? .id == currentUser? .id;
490+ final isAtBottom = ! _showScrollToBottom.value;
491+
492+ // Auto-scroll on own messages always; on others only when the
493+ // user is already at the bottom. For "far from bottom", SPL's
494+ // itemKeyBuilder anchor preservation keeps the visible content
495+ // pinned.
496+ if (! isOwnMessage && ! isAtBottom) return ;
497+
498+ // Synchronous (not post-frame) so SPL's `_scrollTo` clears its
499+ // anchor key before the next `didUpdateWidget` — otherwise
500+ // anchor preservation would yank the layout back and produce a
501+ // visible "shift, then animate" glitch.
502+ if (_scrollController case final controller? when controller.isAttached) {
503+ controller.scrollTo (index: 0 );
504+ }
505+ });
506+ }
507+
489508 @override
490509 void dispose () {
491510 // Tear down anything that could write to [_unreadState] or
@@ -1508,3 +1527,75 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
15081527 }
15091528 }
15101529}
1530+
1531+ extension on ChannelClientState {
1532+ /// Emits each message appended to the bottom of [messages] —
1533+ /// regardless of source: server-confirmed `message.new` events,
1534+ /// optimistic local sends, or any other path that grows the list.
1535+ ///
1536+ /// Detection: the bottom-most message id changed AND its `createdAt`
1537+ /// is strictly after the previously-seen tail. The `createdAt` check
1538+ /// guards against firing on deletion-of-tail (the new tail is older)
1539+ /// and pruning-only emissions.
1540+ ///
1541+ /// Gated on [isUpToDate] (matching Android's `areNewestMessagesLoaded`
1542+ /// and iOS's `isFirstPageLoaded` ): while the channel is loaded around
1543+ /// a historic message, no emissions — composers/UI flows are
1544+ /// expected to load the live tail before triggering a send. The
1545+ /// `false → true` flip itself is a wholesale window replacement,
1546+ /// also suppressed (the next emit re-seeds).
1547+ Stream <Message > get newMessageStream async * {
1548+ var wasUpToDate = isUpToDate;
1549+ var lastSeen = wasUpToDate ? messages.lastOrNull : null ;
1550+
1551+ await for (final emitted in messagesStream) {
1552+ if (! isUpToDate) {
1553+ wasUpToDate = false ;
1554+ lastSeen = null ;
1555+ continue ;
1556+ }
1557+ if (! wasUpToDate) {
1558+ wasUpToDate = true ;
1559+ lastSeen = emitted.lastOrNull;
1560+ continue ;
1561+ }
1562+ final newLast = emitted.lastOrNull;
1563+ if (newLast == null ) {
1564+ lastSeen = null ;
1565+ continue ;
1566+ }
1567+ final isNewArrival = lastSeen == null ||
1568+ (newLast.id != lastSeen.id &&
1569+ newLast.createdAt.isAfter (lastSeen.createdAt));
1570+ lastSeen = newLast;
1571+ if (isNewArrival) yield newLast;
1572+ }
1573+ }
1574+
1575+ /// Same as [newMessageStream] , scoped to a single thread. Threads
1576+ /// load lazily on demand; the first non-empty emission after
1577+ /// subscribe is treated as a seed (the just-loaded page) and not
1578+ /// surfaced as a "new message."
1579+ Stream <Message > newThreadMessageStream (String parentMessageId) async * {
1580+ var seeded = false ;
1581+ Message ? lastSeen;
1582+ await for (final threadsMap in threadsStream) {
1583+ final threadMessages = threadsMap[parentMessageId] ?? const < Message > [];
1584+ final newLast = threadMessages.lastOrNull;
1585+ if (! seeded) {
1586+ seeded = true ;
1587+ lastSeen = newLast;
1588+ continue ;
1589+ }
1590+ if (newLast == null ) {
1591+ lastSeen = null ;
1592+ continue ;
1593+ }
1594+ final isNewArrival = lastSeen == null ||
1595+ (newLast.id != lastSeen.id &&
1596+ newLast.createdAt.isAfter (lastSeen.createdAt));
1597+ lastSeen = newLast;
1598+ if (isNewArrival) yield newLast;
1599+ }
1600+ }
1601+ }
0 commit comments