Skip to content

Commit 2e287d5

Browse files
committed
WIP mark-as-read-on-scroll
TODO: - Audit for efficiency -- I keep feeling dread when adding things like `.sort()` because I know this is a hot codepath :) - Audit for correctness. Can the "range hull" logic end up naively applying its math to two ranges that actually don't have anything to do with each other, because e.g. the narrow changed, and cause hundreds of messages to get marked as read unpredictably? (when is _modelChanged called?) - User setting - Latency compensation (can be a followup maybe?) - Tests
1 parent 7e29108 commit 2e287d5

File tree

5 files changed

+223
-3
lines changed

5 files changed

+223
-3
lines changed

lib/model/message.dart

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import 'dart:async';
2-
import 'dart:collection';
32
import 'dart:convert';
43

4+
import 'package:collection/collection.dart';
55
import 'package:crypto/crypto.dart';
66
import 'package:flutter/foundation.dart';
77

@@ -28,6 +28,8 @@ mixin MessageStore {
2828
void registerMessageList(MessageListView view);
2929
void unregisterMessageList(MessageListView view);
3030

31+
void markReadOnScroll(Iterable<int> messageIds);
32+
3133
Future<void> sendMessage({
3234
required MessageDestination destination,
3335
required String content,
@@ -180,6 +182,37 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore, _OutboxMes
180182
_disposed = true;
181183
}
182184

185+
final _markReadOnScrollQueue = _MarkReadOnScrollQueue();
186+
bool _markReadOnScrollBusy = false;
187+
188+
@override
189+
void markReadOnScroll(Iterable<int> messageIds) async {
190+
final anyAdded = _markReadOnScrollQueue.add(messageIds);
191+
if (!anyAdded) return;
192+
if (_markReadOnScrollBusy) return;
193+
194+
_markReadOnScrollBusy = true;
195+
do {
196+
final batch = _markReadOnScrollQueue.take(1000)
197+
.whereNot((id) => messages[id]?.flags.contains(MessageFlag.read) ?? false).toList();
198+
if (batch.isEmpty) continue;
199+
200+
// TODO mark as read locally for latency compensation
201+
// (in Unreads and on the message objects)
202+
try {
203+
await updateMessageFlags(connection,
204+
messages: batch,
205+
op: UpdateMessageFlagsOp.add,
206+
flag: MessageFlag.read);
207+
} catch (e) {
208+
// do nothing
209+
}
210+
211+
await Future<void>.delayed(Duration(milliseconds: 500));
212+
} while (!_markReadOnScrollQueue.isEmpty);
213+
_markReadOnScrollBusy = false;
214+
}
215+
183216
@override
184217
Future<void> sendMessage({required MessageDestination destination, required String content}) {
185218
assert(!_disposed);
@@ -517,6 +550,38 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore, _OutboxMes
517550
}
518551
}
519552

553+
class _MarkReadOnScrollQueue {
554+
_MarkReadOnScrollQueue();
555+
556+
bool get isEmpty => _queue.isEmpty;
557+
558+
final _set = <int>{};
559+
final _queue = QueueList<int>();
560+
561+
/// Add [messageIds] to the end of the queue,
562+
/// if they aren't already in the queue.
563+
///
564+
/// Returns true if some were added (i.e. weren't already in the queue),
565+
/// false otherwise.
566+
bool add(Iterable<int> messageIds) {
567+
final newMessageIds = messageIds.whereNot((id) => _set.contains(id));
568+
if (newMessageIds.isEmpty) return false;
569+
_queue.addAll(newMessageIds);
570+
_set.addAll(newMessageIds);
571+
return true;
572+
}
573+
574+
List<int> take(int count) {
575+
final result = <int>[];
576+
for (int i = 0; i < count; i++) {
577+
if (_queue.isEmpty) break;
578+
result.add(_queue.removeFirst());
579+
}
580+
_set.removeAll(result);
581+
return result;
582+
}
583+
}
584+
520585
/// The duration an outbox message stays hidden to the user.
521586
///
522587
/// See [OutboxMessageState.waiting].

lib/model/message_list.dart

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,15 @@ mixin _MessageSequence {
222222
return binarySearchByKey(items, messageId, _compareItemToMessageId);
223223
}
224224

225+
List<Message> getMessagesSublist(int firstMessageId, int lastMessageId) {
226+
final firstIndex = _findMessageWithId(firstMessageId);
227+
final lastIndex = _findMessageWithId(lastMessageId);
228+
if (firstIndex == -1 || lastIndex == -1) {
229+
throw StateError('messagesSublist called with message IDs not in list');
230+
}
231+
return messages.sublist(firstIndex, lastIndex + 1);
232+
}
233+
225234
static int _compareItemToMessageId(MessageListItem item, int messageId) {
226235
switch (item) {
227236
case MessageListRecipientHeaderItem(:var message):

lib/model/store.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -754,6 +754,9 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor
754754
void unregisterMessageList(MessageListView view) =>
755755
_messages.unregisterMessageList(view);
756756
@override
757+
void markReadOnScroll(Iterable<int> messageIds) =>
758+
_messages.markReadOnScroll(messageIds);
759+
@override
757760
Future<void> sendMessage({required MessageDestination destination, required String content}) {
758761
assert(!_disposed);
759762
return _messages.sendMessage(destination: destination, content: content);

lib/widgets/action_sheet.dart

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -896,9 +896,11 @@ class MarkAsUnreadButton extends MessageActionSheetMenuItemButton {
896896
}
897897

898898
@override void onPressed() async {
899-
final narrow = findMessageListPage().narrow;
899+
final messageListPage = findMessageListPage();
900900
unawaited(ZulipAction.markNarrowAsUnreadFromMessage(pageContext,
901-
message, narrow));
901+
message, messageListPage.narrow));
902+
// TODO should we alert the user about this change somehow? A snackbar?
903+
messageListPage.markReadOnScrollEnabled = false;
902904
}
903905
}
904906

lib/widgets/message_list.dart

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,14 @@ abstract class MessageListPageState {
139139
///
140140
/// This is null if [MessageList] has not mounted yet.
141141
MessageListView? get model;
142+
143+
/// Whether this message-list page is marking messages as read on scroll.
144+
///
145+
/// This is local for the page and can differ from
146+
/// <TODO reference global setting>, for example it's turned off when
147+
/// pressing "Mark as unread from here" in the message action sheet.
148+
bool get markReadOnScrollEnabled;
149+
set markReadOnScrollEnabled(bool value);
142150
}
143151

144152
class MessageListPage extends StatefulWidget {
@@ -186,10 +194,21 @@ class _MessageListPageState extends State<MessageListPage> implements MessageLis
186194
MessageListView? get model => _messageListKey.currentState?.model;
187195
final GlobalKey<_MessageListState> _messageListKey = GlobalKey();
188196

197+
@override
198+
bool get markReadOnScrollEnabled => _markReadOnScrollEnabled;
199+
late bool _markReadOnScrollEnabled;
200+
@override
201+
set markReadOnScrollEnabled(bool value) {
202+
setState(() {
203+
_markReadOnScrollEnabled = value;
204+
});
205+
}
206+
189207
@override
190208
void initState() {
191209
super.initState();
192210
narrow = widget.initNarrow;
211+
_markReadOnScrollEnabled = true; // TODO initialize from GlobalSettingsStore
193212
}
194213

195214
void _narrowChanged(Narrow newNarrow) {
@@ -298,6 +317,7 @@ class _MessageListPageState extends State<MessageListPage> implements MessageLis
298317
narrow: narrow,
299318
initAnchor: initAnchor,
300319
onNarrowChanged: _narrowChanged,
320+
markReadOnScrollEnabled: markReadOnScrollEnabled,
301321
))),
302322
if (ComposeBox.hasComposeBox(narrow))
303323
ComposeBox(key: _composeBoxKey, narrow: narrow)
@@ -503,24 +523,102 @@ class MessageList extends StatefulWidget {
503523
required this.narrow,
504524
required this.initAnchor,
505525
required this.onNarrowChanged,
526+
required this.markReadOnScrollEnabled,
506527
});
507528

508529
final Narrow narrow;
509530
final Anchor initAnchor;
510531
final void Function(Narrow newNarrow) onNarrowChanged;
532+
final bool markReadOnScrollEnabled;
511533

512534
@override
513535
State<StatefulWidget> createState() => _MessageListState();
514536
}
515537

516538
class _MessageListState extends State<MessageList> with PerAccountStoreAwareStateMixin<MessageList> {
539+
final GlobalKey _scrollViewKey = GlobalKey();
540+
517541
MessageListView get model => _model!;
518542
MessageListView? _model;
519543

520544
final MessageListScrollController scrollController = MessageListScrollController();
521545

522546
final ValueNotifier<bool> _scrollToBottomVisible = ValueNotifier<bool>(false);
523547

548+
List<int>? _messagesRecentlyInViewport;
549+
550+
/// Which messages are onscreen.
551+
///
552+
/// Ignores outbox messages.
553+
///
554+
/// A message is considered onscreen if
555+
/// - it covers the full height of the viewport
556+
/// (i.e. its top is above the viewport top
557+
/// and its bottom is below the viewport bottom) or
558+
/// - its bottom is in the viewport (i.e. between the viewport top and bottom)
559+
///
560+
/// This definition is helpful for mark-as-read-on-scroll, because
561+
/// when this method is called during a fast scroll through many messages,
562+
/// the returned range (almost*) always includes at least one message.
563+
/// For the mark-as-read request, we "fill in" any messages
564+
/// between that message(s) and the messages currently in view.
565+
/// That can be needed when the scrolling is so fast that
566+
/// some messages go by without rendering in the viewport.
567+
///
568+
/// (*It can be empty in the middle of the message list, rarely,
569+
/// if a tall message covers most of the viewport
570+
/// but its bottom isn't visible and the message above it is offscreen,
571+
/// on the far side of a date separator or recipient header.)
572+
List<int> _getMessagesInViewport() {
573+
final messageItemElements = <Element>[];
574+
void visit(Element element) {
575+
final widget = element.widget;
576+
if (widget is MessageItem) {
577+
if (widget.item is MessageListMessageItem) {
578+
messageItemElements.add(element);
579+
}
580+
return;
581+
}
582+
element.visitChildElements(visit);
583+
}
584+
585+
final scrollViewElement = _scrollViewKey.currentContext as Element;
586+
// TODO this frequently ends up walking through a few hundred elements,
587+
// only 5-10 of which end up being MessageItem ones.
588+
scrollViewElement.visitChildElements(visit);
589+
590+
final scrollViewRenderObject = scrollViewElement.renderObject as RenderBox;
591+
final viewportHeight = scrollViewRenderObject.size.height;
592+
593+
final result = <int>[];
594+
for (final element in messageItemElements) {
595+
final renderObject = element.renderObject as RenderBox;
596+
final widget = element.widget as MessageItem;
597+
final item = widget.item as MessageListMessageItem; // (see `visit`)
598+
final message = item.message;
599+
600+
final messageHeight = renderObject.size.height;
601+
final messageTop = renderObject.localToGlobal(
602+
Offset.zero, ancestor: scrollViewRenderObject).dy;
603+
final messageBottom = renderObject.localToGlobal(
604+
Offset(0, messageHeight), ancestor: scrollViewRenderObject).dy;
605+
606+
final doesMessageSpanViewport = messageTop < 0 && messageBottom > viewportHeight;
607+
if (doesMessageSpanViewport) {
608+
result.add(message.id);
609+
break;
610+
}
611+
612+
final isMessageBottomVisible = messageBottom > 0 && messageBottom < viewportHeight;
613+
if (isMessageBottomVisible) {
614+
result.add(message.id);
615+
}
616+
}
617+
// TODO why isn't it sorted already?
618+
result.sort();
619+
return result;
620+
}
621+
524622
@override
525623
void initState() {
526624
super.initState();
@@ -552,6 +650,17 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
552650
bool _prevFetched = false;
553651

554652
void _modelChanged() {
653+
// When you're scrolling quickly, our mark-as-read requests include the
654+
// messages between _recentMessageRangeInViewport and the current range,
655+
// so messages don't get left out because you were scrolling so fast
656+
// that they never rendered onscreen.
657+
//
658+
// Here, the onscreen messages might be totally different,
659+
// and not because of scrolling (e.g. because the narrow changed).
660+
// Avoid "filling in" a mark-as-read request with totally wrong messages,
661+
// by forgetting the old range.
662+
_messagesRecentlyInViewport = null;
663+
555664
if (model.narrow != widget.narrow) {
556665
// Either:
557666
// - A message move event occurred, where propagate mode is
@@ -576,7 +685,37 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
576685
_prevFetched = model.fetched;
577686
}
578687

688+
void _markReadOnScroll() {
689+
final currentRange = _getMessagesInViewport();
690+
if (currentRange.isEmpty) return;
691+
692+
final currentFirst = currentRange.first;
693+
final currentLast = currentRange.last;
694+
final prevFirst = _messagesRecentlyInViewport?.first;
695+
final prevLast = _messagesRecentlyInViewport?.last;
696+
697+
// ("Hull" as in the "convex hull" around the old and new ranges.)
698+
final firstOfHull = switch ((prevFirst, currentFirst)) {
699+
(int previous, int current) => previous < current ? previous : current,
700+
( _, int current) => current,
701+
};
702+
703+
final lastOfHull = switch ((prevLast, currentLast)) {
704+
(int previous, int current) => previous > current ? previous : current,
705+
( _, int current) => current,
706+
};
707+
708+
final sublist = model.getMessagesSublist(firstOfHull, lastOfHull);
709+
model.store.markReadOnScroll(sublist.map((message) => message.id));
710+
711+
_messagesRecentlyInViewport = currentRange;
712+
}
713+
579714
void _handleScrollMetrics(ScrollMetrics scrollMetrics) {
715+
if (widget.markReadOnScrollEnabled) {
716+
_markReadOnScroll();
717+
}
718+
580719
if (scrollMetrics.extentAfter == 0) {
581720
_scrollToBottomVisible.value = false;
582721
} else {
@@ -745,6 +884,8 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
745884
}
746885

747886
return MessageListScrollView(
887+
key: _scrollViewKey,
888+
748889
// TODO: Offer `ScrollViewKeyboardDismissBehavior.interactive` (or
749890
// similar) if that is ever offered:
750891
// https://github.com/flutter/flutter/issues/57609#issuecomment-1355340849

0 commit comments

Comments
 (0)