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