Skip to content

Commit 77e01ac

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 508c019 commit 77e01ac

6 files changed

Lines changed: 518 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: 88 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -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
@@ -449,18 +449,24 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
449449

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;
457-
452+
// `newMessageStream` fires on every path that grows the bottom
453+
// of the list — server-confirmed `message.new` events AND
454+
// optimistic local sends — so we follow to the new bottom for
455+
// both. Gated on `isUpToDate` to match Android/iOS/RN: no
456+
// auto-scroll while the channel is loaded around a historic id.
457+
final newMessageStream = switch (widget.parentMessage?.id) {
458+
final parentId? =>
459+
streamChannel!.channel.state?.newThreadMessageStream(parentId),
460+
_ => streamChannel!.channel.state?.newMessageStream,
461+
};
462+
_messageNewListener = newMessageStream?.listen((newMessage) {
458463
// Don't fight a scroll already in motion (drag, fling, or
459464
// still-running animated scrollTo).
460465
if (_scrollController?.isScrolling == true) return;
461466

462-
final currentUser = streamChannel?.channel.client.state.currentUser;
463-
final isOwnMessage = message.user?.id == currentUser?.id;
467+
final currentUser =
468+
streamChannel?.channel.client.state.currentUser;
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
@@ -1508,3 +1514,76 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
15081514
}
15091515
}
15101516
}
1517+
1518+
extension _NewMessageStreamX on ChannelClientState {
1519+
/// Emits each message appended to the bottom of [messages]
1520+
/// regardless of source: server-confirmed `message.new` events,
1521+
/// optimistic local sends, or any other path that grows the list.
1522+
///
1523+
/// Detection: the bottom-most message id changed AND its `createdAt`
1524+
/// is strictly after the previously-seen tail. The `createdAt` check
1525+
/// guards against firing on deletion-of-tail (the new tail is older)
1526+
/// and pruning-only emissions.
1527+
///
1528+
/// Gated on [isUpToDate] (matching Android's `areNewestMessagesLoaded`
1529+
/// and iOS's `isFirstPageLoaded`): while the channel is loaded around
1530+
/// a historic message, no emissions — composers/UI flows are
1531+
/// expected to load the live tail before triggering a send. The
1532+
/// `false → true` flip itself is a wholesale window replacement,
1533+
/// also suppressed (the next emit re-seeds).
1534+
Stream<Message> get newMessageStream async* {
1535+
var wasUpToDate = isUpToDate;
1536+
var lastSeen = wasUpToDate ? messages.lastOrNull : null;
1537+
1538+
await for (final emitted in messagesStream) {
1539+
if (!isUpToDate) {
1540+
wasUpToDate = false;
1541+
lastSeen = null;
1542+
continue;
1543+
}
1544+
if (!wasUpToDate) {
1545+
wasUpToDate = true;
1546+
lastSeen = emitted.lastOrNull;
1547+
continue;
1548+
}
1549+
final newLast = emitted.lastOrNull;
1550+
if (newLast == null) {
1551+
lastSeen = null;
1552+
continue;
1553+
}
1554+
final isNewArrival = lastSeen == null ||
1555+
(newLast.id != lastSeen.id &&
1556+
newLast.createdAt.isAfter(lastSeen.createdAt));
1557+
lastSeen = newLast;
1558+
if (isNewArrival) yield newLast;
1559+
}
1560+
}
1561+
1562+
/// Same as [newMessageStream], scoped to a single thread. Threads
1563+
/// load lazily on demand; the first non-empty emission after
1564+
/// subscribe is treated as a seed (the just-loaded page) and not
1565+
/// surfaced as a "new message."
1566+
Stream<Message> newThreadMessageStream(String parentMessageId) async* {
1567+
var seeded = false;
1568+
Message? lastSeen;
1569+
await for (final threadsMap in threadsStream) {
1570+
final threadMessages =
1571+
threadsMap[parentMessageId] ?? const <Message>[];
1572+
final newLast = threadMessages.lastOrNull;
1573+
if (!seeded) {
1574+
seeded = true;
1575+
lastSeen = newLast;
1576+
continue;
1577+
}
1578+
if (newLast == null) {
1579+
lastSeen = null;
1580+
continue;
1581+
}
1582+
final isNewArrival = lastSeen == null ||
1583+
(newLast.id != lastSeen.id &&
1584+
newLast.createdAt.isAfter(lastSeen.createdAt));
1585+
lastSeen = newLast;
1586+
if (isNewArrival) yield newLast;
1587+
}
1588+
}
1589+
}

0 commit comments

Comments
 (0)