Skip to content

Commit 7b1dbbc

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 7b1dbbc

3 files changed

Lines changed: 330 additions & 9 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: 83 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,71 @@ 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+
/// Gated on [isUpToDate] (matching Android's `areNewestMessagesLoaded`
1524+
/// and iOS's `isFirstPageLoaded`): while the channel is loaded around
1525+
/// a historic message, no emissions — composers/UI flows are
1526+
/// expected to load the live tail before triggering a send. The
1527+
/// `false → true` flip itself is a wholesale window replacement,
1528+
/// also suppressed (the next emit re-seeds).
1529+
Stream<Message> get newMessageStream async* {
1530+
var wasUpToDate = isUpToDate;
1531+
var state = wasUpToDate ? _SeedState.fromMessages(messages) : _SeedState.empty;
1532+
await for (final emitted in messagesStream) {
1533+
if (!isUpToDate) {
1534+
wasUpToDate = false;
1535+
state = _SeedState.empty;
1536+
continue;
1537+
}
1538+
if (!wasUpToDate) {
1539+
wasUpToDate = true;
1540+
state = _SeedState.fromMessages(emitted);
1541+
continue;
1542+
}
1543+
final result = state.diff(emitted);
1544+
state = result.state;
1545+
if (result.newMessage case final newMessage?) yield newMessage;
1546+
}
1547+
}
1548+
1549+
/// Same as [newMessageStream], scoped to a single thread. Threads
1550+
/// don't have a per-thread `isUpToDate` flag — they're loaded fully
1551+
/// for their parent — so no extra gate is needed.
1552+
Stream<Message> newThreadMessageStream(String parentMessageId) async* {
1553+
var state = _SeedState.fromMessages(
1554+
threads[parentMessageId] ?? const <Message>[],
1555+
);
1556+
final source = threadsStream.map(
1557+
(threads) => threads[parentMessageId] ?? const <Message>[],
1558+
);
1559+
await for (final emitted in source) {
1560+
final result = state.diff(emitted);
1561+
state = result.state;
1562+
if (result.newMessage case final newMessage?) yield newMessage;
1563+
}
1564+
}
1565+
}
1566+
1567+
class _SeedState {
1568+
const _SeedState(this.lastId, this.length);
1569+
factory _SeedState.fromMessages(List<Message> messages) =>
1570+
_SeedState(messages.lastOrNull?.id, messages.length);
1571+
static const empty = _SeedState(null, 0);
1572+
1573+
final String? lastId;
1574+
final int length;
1575+
1576+
({_SeedState state, Message? newMessage}) diff(List<Message> emitted) {
1577+
final newLast = emitted.lastOrNull;
1578+
final next = _SeedState(newLast?.id, emitted.length);
1579+
if (newLast == null) return (state: next, newMessage: null);
1580+
final grew = emitted.length > length;
1581+
final changed = newLast.id != lastId;
1582+
return (state: next, newMessage: grew && changed ? newLast : null);
1583+
}
1584+
}
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)