Skip to content

Commit a483cc4

Browse files
xsahil03xclaude
andcommitted
fix(ui): auto-scroll on optimistic local messages
Subscribe to `channel.state.messagesStream` (or `threadsStream[parentId]` in thread mode) instead of `channel.on(EventType.messageNew)` so the list follows to the new bottom message the moment it lands in state. The event path only fires on server-confirmed messages, which meant the user's own send wasn't auto-scrolled until the server round-trip completed. The data-source-driven pattern matches what the Android, iOS, and React Native SDKs do. New-message detection uses a `lengthGrew && lastChanged` check between emissions; the bottom-most snapshot is seeded from current state on subscribe so we don't auto-scroll on the BehaviorSubject replay. The synchronous `controller.scrollTo(index: 0)` call still clears SPL's anchor key before `didUpdateWidget` (no race). Adds an `auto_scroll_test.dart` covering: other-user-at-bottom, other-user-scrolled-up, own-message-scrolled-up, optimistic local send, and rapid burst. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8b7b890 commit a483cc4

4 files changed

Lines changed: 469 additions & 11 deletions

File tree

packages/stream_chat_flutter/CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
## Upcoming Changes
2+
3+
🐞 Fixed
4+
5+
- Fixed `StreamMessageListView` not auto-scrolling to the bottom on the user's own outgoing message
6+
until the server confirmed it.
7+
18
## 9.24.0
29

310
✅ Added

packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import 'dart:math';
55
import 'package:collection/collection.dart';
66
import 'package:flutter/material.dart';
77
import 'package:flutter_portal/flutter_portal.dart';
8+
import 'package:rxdart/rxdart.dart';
89
import 'package:stream_chat_flutter/scrollable_positioned_list/scrollable_positioned_list.dart';
910
import 'package:stream_chat_flutter/src/message_list_view/floating_date_divider.dart';
1011
import 'package:stream_chat_flutter/src/message_list_view/loading_indicator.dart';
@@ -16,6 +17,8 @@ import 'package:stream_chat_flutter/src/message_widget/ephemeral_message.dart';
1617
import 'package:stream_chat_flutter/src/misc/empty_widget.dart';
1718
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
1819

20+
part 'new_message_stream.dart';
21+
1922
/// Spacing Types (These are properties of a message to help inform the decision
2023
/// of how much space / which widget to build after it)
2124
enum SpacingType {
@@ -416,7 +419,7 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
416419
MessageListController get _messageListController =>
417420
widget.messageListController ?? _defaultController;
418421

419-
StreamSubscription? _messageNewListener;
422+
StreamSubscription<Message>? _messageNewListener;
420423
StreamSubscription? _userReadListener;
421424

422425
@override
@@ -444,17 +447,16 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
444447
debouncedMarkRead.cancel();
445448
debouncedMarkThreadRead.cancel();
446449

447-
_messageNewListener?.cancel();
448-
_userReadListener?.cancel();
449-
450450
_unreadState.value = _readUnreadSnapshot();
451451

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;
452+
final state = streamChannel?.channel.state;
453+
final newMessageStream = switch (widget.parentMessage?.id) {
454+
final parentId? => state?.newThreadMessageStream(parentId),
455+
_ => state?.newMessageStream,
456+
};
457457

458+
_messageNewListener?.cancel();
459+
_messageNewListener = newMessageStream?.listen((message) {
458460
// Don't fight a scroll already in motion (drag, fling, or
459461
// still-running animated scrollTo).
460462
if (_scrollController?.isScrolling == true) return;
@@ -479,8 +481,8 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
479481
}
480482
});
481483

482-
_userReadListener =
483-
streamChannel!.channel.state?.currentUserReadStream.listen((_) {
484+
_userReadListener?.cancel();
485+
_userReadListener = state?.currentUserReadStream.listen((_) {
484486
_unreadState.value = _readUnreadSnapshot();
485487
});
486488
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
part of 'message_list_view.dart';
2+
3+
/// Stream helpers for observing newly arrived messages on a channel,
4+
/// either in the main message list or scoped to a thread.
5+
extension on ChannelClientState {
6+
// True when [candidate] represents a newer tail message than
7+
// [previous].
8+
//
9+
// A new arrival requires both a different id *and* a strictly later
10+
// [Message.createdAt], so edits, reactions, and reorderings are
11+
// ignored.
12+
bool _isNewTailArrival(Message candidate, Message? previous) {
13+
if (previous == null) return true;
14+
return candidate.id != previous.id &&
15+
candidate.createdAt.isAfter(previous.createdAt);
16+
}
17+
18+
/// A stream that emits each newly arrived bottom message in
19+
/// [messages].
20+
///
21+
/// Fires for every upstream that grows the list, including
22+
/// server-confirmed `message.new` events, optimistic local sends,
23+
/// and any other update that appends to the tail.
24+
///
25+
/// A new arrival is detected when the bottom message's id changes
26+
/// **and** its [Message.createdAt] is strictly after the previously
27+
/// observed tail. Edits, reactions, tail deletions, and pruning are
28+
/// therefore ignored.
29+
///
30+
/// Gated on [isUpToDate]: while the channel is loaded around a
31+
/// historic message the stream stays silent, and the first emission
32+
/// after the gate re-opens re-seeds the baseline without yielding.
33+
Stream<Message> get newMessageStream async* {
34+
var wasUpToDate = isUpToDate;
35+
var lastSeen = wasUpToDate ? messages.lastOrNull : null;
36+
37+
await for (final updated in messagesStream) {
38+
if (!isUpToDate) {
39+
wasUpToDate = false;
40+
lastSeen = null;
41+
continue;
42+
}
43+
44+
// Re-seed without yielding: the gate just re-opened, the next
45+
// emission is a wholesale window replacement, not an arrival.
46+
if (!wasUpToDate) {
47+
wasUpToDate = true;
48+
lastSeen = updated.lastOrNull;
49+
continue;
50+
}
51+
52+
final newLast = updated.lastOrNull;
53+
if (newLast == null) {
54+
lastSeen = null;
55+
continue;
56+
}
57+
58+
final isNewArrival = _isNewTailArrival(newLast, lastSeen);
59+
lastSeen = newLast;
60+
if (!isNewArrival) continue;
61+
yield newLast;
62+
}
63+
}
64+
65+
/// A stream that emits each newly arrived reply at the bottom of
66+
/// the thread identified by [parentMessageId].
67+
///
68+
/// Fires for every upstream that grows the thread, including
69+
/// server-confirmed replies, optimistic local sends, and any other
70+
/// update that appends to the tail of [threads].
71+
///
72+
/// A new arrival is detected when the bottom reply's id changes
73+
/// **and** its [Message.createdAt] is strictly after the previously
74+
/// observed tail. Edits, reactions, tail deletions, and pruning are
75+
/// therefore ignored.
76+
///
77+
/// Threads load lazily, so the stream stays silent until [threads]
78+
/// carries replies for [parentMessageId]; that first snapshot seeds
79+
/// the baseline without yielding.
80+
Stream<Message> newThreadMessageStream(String parentMessageId) async* {
81+
final threadMessages =
82+
threadsStream.mapNotNull((it) => it[parentMessageId]);
83+
84+
var lastSeen = threads[parentMessageId]?.lastOrNull;
85+
await for (final updated in threadMessages) {
86+
final newLast = updated.lastOrNull;
87+
if (newLast == null) {
88+
lastSeen = null;
89+
continue;
90+
}
91+
92+
final isNewArrival = _isNewTailArrival(newLast, lastSeen);
93+
lastSeen = newLast;
94+
if (!isNewArrival) continue;
95+
yield newLast;
96+
}
97+
}
98+
}

0 commit comments

Comments
 (0)