Skip to content

Commit 1a727ec

Browse files
authored
Merge branch 'master' into feature/FLU-485_optimize_read_message_from_db
2 parents fa6898e + 7f0804d commit 1a727ec

12 files changed

Lines changed: 637 additions & 16 deletions

File tree

packages/stream_chat_flutter/CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
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+
- Fixed a `FlutterError` ("A RenderViewport exceeded its maximum number of layout cycles") that
8+
could occur when fast-scrolling through the message list.
9+
110
## 9.24.0
211

312
✅ Added

packages/stream_chat_flutter/lib/scrollable_positioned_list/src/viewport.dart

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,9 @@ class UnboundedRenderViewport extends RenderViewport {
219219
final fitAnchor = _minScrollExtent.abs() / mainAxisExtent;
220220
if (fitAnchor != effectiveAnchor) {
221221
effectiveAnchor = fitAnchor;
222-
count += 1;
222+
// Do not increment `count` here. This is a deliberate one-time
223+
// anchor correction (not an oscillation), so it must not consume
224+
// a slot from the sliver-correction budget.
223225
continue;
224226
}
225227
}

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

Lines changed: 10 additions & 11 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,17 +444,16 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
444444
debouncedMarkRead.cancel();
445445
debouncedMarkThreadRead.cancel();
446446

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

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

455+
_messageNewListener?.cancel();
456+
_messageNewListener = newMessageStream?.listen((message) {
458457
// Don't fight a scroll already in motion (drag, fling, or
459458
// still-running animated scrollTo).
460459
if (_scrollController?.isScrolling == true) return;
@@ -479,8 +478,8 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
479478
}
480479
});
481480

482-
_userReadListener =
483-
streamChannel!.channel.state?.currentUserReadStream.listen((_) {
481+
_userReadListener?.cancel();
482+
_userReadListener = state?.currentUserReadStream.listen((_) {
484483
_unreadState.value = _readUnreadSnapshot();
485484
});
486485
}

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

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import 'package:collection/collection.dart';
2+
import 'package:rxdart/rxdart.dart';
23
import 'package:stream_chat_flutter/scrollable_positioned_list/scrollable_positioned_list.dart';
34
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
45

@@ -101,3 +102,100 @@ bool isElementAtIndexVisible(
101102
bool isInitialMessage(String id, StreamChannelState? channelState) {
102103
return channelState!.initialMessageId == id;
103104
}
105+
106+
/// Stream helpers for observing newly arrived messages on a channel,
107+
/// either in the main message list or scoped to a thread.
108+
extension NewMessageStreamX on ChannelClientState {
109+
// True when [candidate] represents a newer tail message than
110+
// [previous].
111+
//
112+
// A new arrival requires both a different id *and* a strictly later
113+
// [Message.createdAt], so edits, reactions, and reorderings are
114+
// ignored.
115+
bool _isNewTailArrival(Message candidate, Message? previous) {
116+
if (previous == null) return true;
117+
return candidate.id != previous.id &&
118+
candidate.createdAt.isAfter(previous.createdAt);
119+
}
120+
121+
/// A stream that emits each newly arrived bottom message in
122+
/// [messages].
123+
///
124+
/// Fires for every upstream that grows the list, including
125+
/// server-confirmed `message.new` events, optimistic local sends,
126+
/// and any other update that appends to the tail.
127+
///
128+
/// A new arrival is detected when the bottom message's id changes
129+
/// **and** its [Message.createdAt] is strictly after the previously
130+
/// observed tail. Edits, reactions, tail deletions, and pruning are
131+
/// therefore ignored.
132+
///
133+
/// Gated on [isUpToDate]: while the channel is loaded around a
134+
/// historic message the stream stays silent, and the first emission
135+
/// after the gate re-opens re-seeds the baseline without yielding.
136+
Stream<Message> get newMessageStream async* {
137+
var wasUpToDate = isUpToDate;
138+
var lastSeen = wasUpToDate ? messages.lastOrNull : null;
139+
140+
await for (final updated in messagesStream) {
141+
if (!isUpToDate) {
142+
wasUpToDate = false;
143+
lastSeen = null;
144+
continue;
145+
}
146+
147+
// Re-seed without yielding: the gate just re-opened, the next
148+
// emission is a wholesale window replacement, not an arrival.
149+
if (!wasUpToDate) {
150+
wasUpToDate = true;
151+
lastSeen = updated.lastOrNull;
152+
continue;
153+
}
154+
155+
final newLast = updated.lastOrNull;
156+
if (newLast == null) {
157+
lastSeen = null;
158+
continue;
159+
}
160+
161+
final isNewArrival = _isNewTailArrival(newLast, lastSeen);
162+
lastSeen = newLast;
163+
if (!isNewArrival) continue;
164+
yield newLast;
165+
}
166+
}
167+
168+
/// A stream that emits each newly arrived reply at the bottom of
169+
/// the thread identified by [parentMessageId].
170+
///
171+
/// Fires for every upstream that grows the thread, including
172+
/// server-confirmed replies, optimistic local sends, and any other
173+
/// update that appends to the tail of [threads].
174+
///
175+
/// A new arrival is detected when the bottom reply's id changes
176+
/// **and** its [Message.createdAt] is strictly after the previously
177+
/// observed tail. Edits, reactions, tail deletions, and pruning are
178+
/// therefore ignored.
179+
///
180+
/// Threads load lazily, so the stream stays silent until [threads]
181+
/// carries replies for [parentMessageId]; that first snapshot seeds
182+
/// the baseline without yielding.
183+
Stream<Message> newThreadMessageStream(String parentMessageId) async* {
184+
final threadMessages =
185+
threadsStream.mapNotNull((it) => it[parentMessageId]);
186+
187+
var lastSeen = threads[parentMessageId]?.lastOrNull;
188+
await for (final updated in threadMessages) {
189+
final newLast = updated.lastOrNull;
190+
if (newLast == null) {
191+
lastSeen = null;
192+
continue;
193+
}
194+
195+
final isNewArrival = _isNewTailArrival(newLast, lastSeen);
196+
lastSeen = newLast;
197+
if (!isNewArrival) continue;
198+
yield newLast;
199+
}
200+
}
201+
}

0 commit comments

Comments
 (0)