Skip to content

Commit 76ad959

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 76ad959

3 files changed

Lines changed: 480 additions & 31 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: 122 additions & 31 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
@@ -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

Comments
 (0)