Skip to content

Commit f27b413

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 f27b413

3 files changed

Lines changed: 289 additions & 8 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: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -416,9 +416,15 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
416416
MessageListController get _messageListController =>
417417
widget.messageListController ?? _defaultController;
418418

419-
StreamSubscription? _messageNewListener;
419+
StreamSubscription<List<Message>>? _messageNewListener;
420420
StreamSubscription? _userReadListener;
421421

422+
// Tracks the bottom-most message between [messagesStream] emissions so
423+
// we can distinguish "a new message arrived" (length grew + last id
424+
// changed) from edits, reactions, deletions, and top-pagination.
425+
String? _lastSeenMessageId;
426+
int _lastSeenMessageCount = 0;
427+
422428
@override
423429
void initState() {
424430
super.initState();
@@ -449,18 +455,46 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
449455

450456
_unreadState.value = _readUnreadSnapshot();
451457

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;
458+
// Subscribe to the messages collection (channel for main view,
459+
// thread for a parent message). Watching state — rather than
460+
// `channel.on(EventType.messageNew)` — also covers optimistic
461+
// local sends: the message lands in state the instant the user
462+
// hits send and we can scroll to it before the server round-trip
463+
// completes. Matches what the Android/iOS/RN SDKs do.
464+
// Seed the bottom-most snapshot from current state so we don't
465+
// auto-scroll on the BehaviorSubject's replay (or on the first
466+
// emit equal to current state).
467+
final initial = switch (widget.parentMessage?.id) {
468+
final parentId? =>
469+
streamChannel!.channel.state?.threads[parentId] ?? const <Message>[],
470+
_ => streamChannel!.channel.state?.messages ?? const <Message>[],
471+
};
472+
_lastSeenMessageId = initial.lastOrNull?.id;
473+
_lastSeenMessageCount = initial.length;
474+
475+
final messagesStream = switch (widget.parentMessage?.id) {
476+
final parentId? => streamChannel!.channel.state?.threadsStream
477+
.map((threads) => threads[parentId] ?? const <Message>[]),
478+
_ => streamChannel!.channel.state?.messagesStream,
479+
};
480+
_messageNewListener = messagesStream?.listen((messages) {
481+
final newLastId = messages.lastOrNull?.id;
482+
final lengthGrew = messages.length > _lastSeenMessageCount;
483+
final lastChanged = newLastId != _lastSeenMessageId;
484+
_lastSeenMessageCount = messages.length;
485+
_lastSeenMessageId = newLastId;
486+
487+
if (!lengthGrew || !lastChanged) return;
457488

489+
final newMessage = messages.last;
490+
if (newMessage.parentId != widget.parentMessage?.id) return;
458491
// Don't fight a scroll already in motion (drag, fling, or
459492
// still-running animated scrollTo).
460493
if (_scrollController?.isScrolling == true) return;
461494

462-
final currentUser = streamChannel?.channel.client.state.currentUser;
463-
final isOwnMessage = message.user?.id == currentUser?.id;
495+
final currentUser =
496+
streamChannel?.channel.client.state.currentUser;
497+
final isOwnMessage = newMessage.user?.id == currentUser?.id;
464498
final isAtBottom = !_showScrollToBottom.value;
465499

466500
// Auto-scroll on own messages always; on others only when the
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
// Pins `StreamMessageListView`'s auto-scroll-on-new-message behaviour:
2+
// the listener subscribes to `channel.state.messagesStream` so it
3+
// triggers on optimistic local sends too (not just server-confirmed
4+
// `messageNew` events), and the SPL scroll math + listener timing must
5+
// not race with anchor preservation.
6+
7+
import 'dart:async';
8+
9+
import 'package:flutter/material.dart';
10+
import 'package:flutter/services.dart';
11+
import 'package:flutter_test/flutter_test.dart';
12+
import 'package:mocktail/mocktail.dart';
13+
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
14+
15+
import '../../test_utils/data_generator.dart';
16+
import '../mocks.dart';
17+
18+
void main() {
19+
late StreamChatClient client;
20+
late Channel channel;
21+
late ChannelClientState channelClientState;
22+
late ClientState clientState;
23+
late OwnUser ownUser;
24+
25+
late StreamController<bool> isUpToDateController;
26+
late StreamController<int> unreadCountController;
27+
late StreamController<List<Message>> messagesController;
28+
29+
setUp(() {
30+
client = MockClient();
31+
clientState = MockClientState();
32+
when(() => client.state).thenAnswer((_) => clientState);
33+
ownUser = OwnUser(id: 'ownid');
34+
when(() => clientState.currentUser).thenReturn(ownUser);
35+
when(() => clientState.currentUserStream).thenAnswer((_) => Stream.value(ownUser));
36+
37+
isUpToDateController = StreamController<bool>.broadcast();
38+
unreadCountController = StreamController<int>.broadcast();
39+
messagesController = StreamController<List<Message>>.broadcast();
40+
addTearDown(isUpToDateController.close);
41+
addTearDown(unreadCountController.close);
42+
addTearDown(messagesController.close);
43+
44+
channel = MockChannel();
45+
channelClientState = MockChannelState();
46+
when(() => channel.client).thenReturn(client);
47+
when(() => channel.state).thenReturn(channelClientState);
48+
49+
when(() => channelClientState.threadsStream).thenAnswer((_) => const Stream.empty());
50+
when(() => channelClientState.isUpToDateStream).thenAnswer((_) => isUpToDateController.stream);
51+
when(() => channelClientState.unreadCountStream).thenAnswer((_) => unreadCountController.stream);
52+
when(() => channelClientState.readStream).thenAnswer((_) => const Stream.empty());
53+
when(() => channelClientState.read).thenReturn([]);
54+
when(() => channelClientState.membersStream).thenAnswer((_) => const Stream.empty());
55+
when(() => channelClientState.members).thenReturn([]);
56+
when(() => channelClientState.currentUserRead).thenReturn(null);
57+
when(() => channelClientState.currentUserReadStream).thenAnswer((_) => const Stream.empty());
58+
when(() => channelClientState.messagesStream).thenAnswer((_) => messagesController.stream);
59+
60+
when(() => channel.markRead(messageId: any(named: 'messageId'))).thenAnswer((_) async => EmptyResponse());
61+
});
62+
63+
Future<void> pumpMessageList(
64+
WidgetTester tester, {
65+
required List<Message> messages,
66+
bool isUpToDate = true,
67+
int unreadCount = 0,
68+
}) async {
69+
when(() => channelClientState.isUpToDate).thenReturn(isUpToDate);
70+
when(() => channelClientState.unreadCount).thenReturn(unreadCount);
71+
when(() => channelClientState.messages).thenReturn(messages);
72+
73+
await tester.runAsync(() async {
74+
await tester.pumpWidget(
75+
MaterialApp(
76+
home: DefaultAssetBundle(
77+
bundle: rootBundle,
78+
child: StreamChat(
79+
client: client,
80+
streamChatThemeData: StreamChatThemeData.light(),
81+
child: StreamChannel(
82+
channel: channel,
83+
child: const StreamMessageListView(),
84+
),
85+
),
86+
),
87+
),
88+
);
89+
isUpToDateController.add(isUpToDate);
90+
unreadCountController.add(unreadCount);
91+
messagesController.add(messages);
92+
await tester.pumpAndSettle();
93+
});
94+
}
95+
96+
// Appends to the end because production state.messages is oldest-first.
97+
// Mirrors what `state.updateMessage(...)` does for both optimistic local
98+
// sends and server-confirmed `messageNew` events: it updates
99+
// `channel.state.messages` and the stream emits the new list.
100+
Future<void> deliverNewMessage(
101+
WidgetTester tester, {
102+
required Message newMessage,
103+
required List<Message> existing,
104+
}) async {
105+
final updated = [...existing, newMessage];
106+
when(() => channelClientState.messages).thenReturn(updated);
107+
await tester.runAsync(() async {
108+
messagesController.add(updated);
109+
await tester.pumpAndSettle();
110+
});
111+
}
112+
113+
group('auto-scroll on new message', () {
114+
testWidgets(
115+
'new message from another user while at bottom → renders at the bottom',
116+
(tester) async {
117+
final other = User(id: 'otherid');
118+
final messages = generateConversation(20, users: [other]).reversed.toList();
119+
120+
await pumpMessageList(tester, messages: messages);
121+
expect(find.byType(FloatingActionButton), findsNothing);
122+
123+
final newMessage = Message(
124+
id: 'new-msg-other',
125+
text: 'A fresh incoming message',
126+
user: other,
127+
createdAt: DateTime.now(),
128+
);
129+
await deliverNewMessage(tester, newMessage: newMessage, existing: messages);
130+
131+
expect(find.text(newMessage.text!), findsOneWidget);
132+
expect(find.byType(FloatingActionButton), findsNothing);
133+
},
134+
);
135+
136+
testWidgets(
137+
'new message from another user while scrolled up → does NOT auto-scroll',
138+
(tester) async {
139+
final other = User(id: 'otherid');
140+
final messages = generateConversation(40, users: [other]).reversed.toList();
141+
142+
await pumpMessageList(tester, messages: messages);
143+
144+
await tester.drag(find.byType(StreamMessageListView), const Offset(0, 400));
145+
await tester.pumpAndSettle();
146+
expect(find.byType(FloatingActionButton), findsOneWidget);
147+
148+
final newMessage = Message(
149+
id: 'new-msg-other-while-scrolled-up',
150+
text: 'Should NOT pull the user back',
151+
user: other,
152+
createdAt: DateTime.now(),
153+
);
154+
await deliverNewMessage(tester, newMessage: newMessage, existing: messages);
155+
156+
expect(find.byType(FloatingActionButton), findsOneWidget);
157+
expect(find.text(newMessage.text!), findsNothing);
158+
},
159+
);
160+
161+
testWidgets(
162+
'own message while scrolled up → DOES auto-scroll to bottom',
163+
(tester) async {
164+
final other = User(id: 'otherid');
165+
final messages = generateConversation(40, users: [other]).reversed.toList();
166+
167+
await pumpMessageList(tester, messages: messages);
168+
169+
await tester.drag(find.byType(StreamMessageListView), const Offset(0, 400));
170+
await tester.pumpAndSettle();
171+
expect(find.byType(FloatingActionButton), findsOneWidget);
172+
173+
final ownMessage = Message(
174+
id: 'new-msg-own',
175+
text: 'My own outgoing message',
176+
user: ownUser,
177+
createdAt: DateTime.now(),
178+
);
179+
await deliverNewMessage(tester, newMessage: ownMessage, existing: messages);
180+
181+
expect(find.text(ownMessage.text!), findsOneWidget);
182+
expect(find.byType(FloatingActionButton), findsNothing);
183+
},
184+
);
185+
186+
// The whole point of this listener change: the user's outgoing
187+
// message hits state via `state.updateMessage(...)` before the
188+
// server confirms (no `messageNew` event yet). Subscribing to the
189+
// messages stream covers that path.
190+
testWidgets(
191+
'optimistic local message → auto-scrolls before server confirmation',
192+
(tester) async {
193+
final other = User(id: 'otherid');
194+
final messages = generateConversation(20, users: [other]).reversed.toList();
195+
196+
await pumpMessageList(tester, messages: messages);
197+
expect(find.byType(FloatingActionButton), findsNothing);
198+
199+
final ownLocalMessage = Message(
200+
id: 'optimistic-local',
201+
text: 'Sending…',
202+
user: ownUser,
203+
localCreatedAt: DateTime.now(),
204+
state: MessageState.sending,
205+
);
206+
await deliverNewMessage(tester, newMessage: ownLocalMessage, existing: messages);
207+
208+
expect(find.text(ownLocalMessage.text!), findsOneWidget);
209+
expect(find.byType(FloatingActionButton), findsNothing);
210+
},
211+
);
212+
213+
testWidgets(
214+
'rapid burst of messages while at bottom → final layout shows newest',
215+
(tester) async {
216+
final other = User(id: 'otherid');
217+
final messages = generateConversation(20, users: [other]).reversed.toList();
218+
219+
await pumpMessageList(tester, messages: messages);
220+
expect(find.byType(FloatingActionButton), findsNothing);
221+
222+
var existing = messages;
223+
late Message latest;
224+
for (var i = 0; i < 5; i++) {
225+
latest = Message(
226+
id: 'burst-$i',
227+
text: 'Burst message $i',
228+
user: other,
229+
createdAt: DateTime.now().add(Duration(milliseconds: i)),
230+
);
231+
await deliverNewMessage(tester, newMessage: latest, existing: existing);
232+
existing = [...existing, latest];
233+
}
234+
235+
expect(find.text(latest.text!), findsOneWidget);
236+
expect(find.byType(FloatingActionButton), findsNothing);
237+
},
238+
);
239+
});
240+
}

0 commit comments

Comments
 (0)