@@ -139,6 +139,14 @@ abstract class MessageListPageState {
139
139
///
140
140
/// This is null if [MessageList] has not mounted yet.
141
141
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);
142
150
}
143
151
144
152
class MessageListPage extends StatefulWidget {
@@ -186,10 +194,21 @@ class _MessageListPageState extends State<MessageListPage> implements MessageLis
186
194
MessageListView ? get model => _messageListKey.currentState? .model;
187
195
final GlobalKey <_MessageListState > _messageListKey = GlobalKey ();
188
196
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
+
189
207
@override
190
208
void initState () {
191
209
super .initState ();
192
210
narrow = widget.initNarrow;
211
+ _markReadOnScrollEnabled = true ; // TODO initialize from GlobalSettingsStore
193
212
}
194
213
195
214
void _narrowChanged (Narrow newNarrow) {
@@ -298,6 +317,7 @@ class _MessageListPageState extends State<MessageListPage> implements MessageLis
298
317
narrow: narrow,
299
318
initAnchor: initAnchor,
300
319
onNarrowChanged: _narrowChanged,
320
+ markReadOnScrollEnabled: markReadOnScrollEnabled,
301
321
))),
302
322
if (ComposeBox .hasComposeBox (narrow))
303
323
ComposeBox (key: _composeBoxKey, narrow: narrow)
@@ -503,24 +523,102 @@ class MessageList extends StatefulWidget {
503
523
required this .narrow,
504
524
required this .initAnchor,
505
525
required this .onNarrowChanged,
526
+ required this .markReadOnScrollEnabled,
506
527
});
507
528
508
529
final Narrow narrow;
509
530
final Anchor initAnchor;
510
531
final void Function (Narrow newNarrow) onNarrowChanged;
532
+ final bool markReadOnScrollEnabled;
511
533
512
534
@override
513
535
State <StatefulWidget > createState () => _MessageListState ();
514
536
}
515
537
516
538
class _MessageListState extends State <MessageList > with PerAccountStoreAwareStateMixin <MessageList > {
539
+ final GlobalKey _scrollViewKey = GlobalKey ();
540
+
517
541
MessageListView get model => _model! ;
518
542
MessageListView ? _model;
519
543
520
544
final MessageListScrollController scrollController = MessageListScrollController ();
521
545
522
546
final ValueNotifier <bool > _scrollToBottomVisible = ValueNotifier <bool >(false );
523
547
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
+
524
622
@override
525
623
void initState () {
526
624
super .initState ();
@@ -552,6 +650,17 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
552
650
bool _prevFetched = false ;
553
651
554
652
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
+
555
664
if (model.narrow != widget.narrow) {
556
665
// Either:
557
666
// - A message move event occurred, where propagate mode is
@@ -576,7 +685,37 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
576
685
_prevFetched = model.fetched;
577
686
}
578
687
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
+
579
714
void _handleScrollMetrics (ScrollMetrics scrollMetrics) {
715
+ if (widget.markReadOnScrollEnabled) {
716
+ _markReadOnScroll ();
717
+ }
718
+
580
719
if (scrollMetrics.extentAfter == 0 ) {
581
720
_scrollToBottomVisible.value = false ;
582
721
} else {
@@ -745,6 +884,8 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
745
884
}
746
885
747
886
return MessageListScrollView (
887
+ key: _scrollViewKey,
888
+
748
889
// TODO: Offer `ScrollViewKeyboardDismissBehavior.interactive` (or
749
890
// similar) if that is ever offered:
750
891
// https://github.com/flutter/flutter/issues/57609#issuecomment-1355340849
0 commit comments