@@ -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
144152class 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
516538class _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