-
Notifications
You must be signed in to change notification settings - Fork 312
Implement mark-as-read-on-scroll, v1 #1574
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
2e287d5
to
4016b74
Compare
Great, glad to see this progress.
If the model's narrow changes, that will always cause its listeners to be notified. (It'd be a bug in the model if data like that could change without notifying listeners.) So in particular _modelChanged will get called. To verify that listeners do always get called, look at the references to |
lib/widgets/message_list.dart
Outdated
// TODO this frequently ends up walking through a few hundred elements, | ||
// only 5-10 of which end up being MessageItem ones. | ||
scrollViewElement.visitChildElements(visit); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, it should be possible to make this more focused so that it only goes down the relevant paths of the tree — only to the list items (be they MessageItem or date separator or recipient header), not to any of their descendants or to other parts of the tree. We can take a look together.
Ah I do trust that _modelChanged will get called when the model's narrow changes, I'm just not totally sure the I might think more clearly about this in the office tomorrow 😛 |
lib/widgets/message_list.dart
Outdated
result.add(message.id); | ||
} | ||
} | ||
// TODO why isn't it sorted already? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This visits the items in tree order. Within each list sliver, the tree order of the items (their order as children of the sliver) is in the sliver's growth direction. So for the top sliver, the items appear from bottom to top, the reverse of their order as Zulip messages.
lib/widgets/message_list.dart
Outdated
/// A message is considered onscreen if | ||
/// - it covers the full height of the viewport | ||
/// (i.e. its top is above the viewport top | ||
/// and its bottom is below the viewport bottom) or | ||
/// - its bottom is in the viewport (i.e. between the viewport top and bottom) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm, in the first case I'd think we'd rather wait to mark the message read until the user reaches the bottom of the message.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
With the new "Open message feeds at" setting, It'll be more common than before to approach unread messages from below. There's also the point about how we'd like there to be at least one message in the result, so we can do the convex-hull thing from one result to the next result.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm, in the first case I'd think we'd rather wait to mark the message read until the user reaches the bottom of the message.
My more-awake brain today has come around to agreeing with this :)
lib/model/message.dart
Outdated
} catch (e) { | ||
// do nothing | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This part goes better if the messages only get removed from the queue after the request succeeds. (I believe that's what the legacy app does.)
That way if there's a network hiccup or something, those messages effectively get retried and eventually marked as read.
When we merge this, we should revert the hunk from zulip/zulip#34887 (comment). |
4016b74
to
a372d91
Compare
a372d91
to
7280e57
Compare
Revision pushed; Greg, PTAL! |
7280e57
to
1f2367b
Compare
lib/model/message.dart
Outdated
i++; | ||
final message = messages[messageId]; | ||
if (message == null) continue; | ||
if (!message.flags.contains(MessageFlag.read)) { | ||
toSend.add(message.id); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Equivalent and probably better, right:
final message = messages[messageId];
if (message != null && !message.flags.contains(MessageFlag.read)) {
toSend.add(message.id);
}
i++;
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sure.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Exciting! Here's comments from a closer read just now, of the model/message.dart
and model/message_list.dart
changes.
lib/model/message.dart
Outdated
if (toSend.length == _markReadOnScrollBatchSize) { | ||
break; | ||
} | ||
i++; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: a more descriptive name is probably helpful for this; perhaps numFromQueue
?
lib/model/message.dart
Outdated
|
||
await Future<void>.delayed(_markReadOnScrollDebounceDuration); | ||
if (_disposed) return; | ||
} while (!_markReadOnScrollQueue.isEmpty); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: isNotEmpty
} while (!_markReadOnScrollQueue.isEmpty); | |
} while (_markReadOnScrollQueue.isNotEmpty); |
lib/model/message.dart
Outdated
final anyAdded = _markReadOnScrollQueue.add(messageIds); | ||
if (!anyAdded) return; | ||
if (_markReadOnScrollBusy) return; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is the anyAdded condition needed? If the queue is nonempty but the "busy" flag is false, then nothing's working on the queue; so then it seems good to start doing so, even if this call didn't happen to add anything new to the queue.
(Also it seems like a bug if that situation happens in the first place.)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Without that condition, the add
implementation can also get a bit simpler.
lib/model/message.dart
Outdated
// (in Unreads and on the message objects) | ||
try { | ||
await updateMessageFlags(connection, | ||
messages: toSend.toList(), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks like this method could take type List
for toSend, and then no need to copy here. (The argument it gets passed is already a List, and isn't getting reused to be modified by anything else so there's no semantic need to copy it.)
lib/model/message.dart
Outdated
} while (!_markReadOnScrollQueue.isEmpty); | ||
_markReadOnScrollBusy = false; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh, speaking of the possibility of bugs causing surprising states. This clearing of the busy flag should be in a finally
:
} while (!_markReadOnScrollQueue.isEmpty); | |
_markReadOnScrollBusy = false; | |
} while (!_markReadOnScrollQueue.isEmpty); | |
} finally { | |
if (!_disposed) { | |
_markReadOnScrollBusy = false; | |
} | |
} |
with the try
starting immediately after setting the flag:
_markReadOnScrollBusy = true;
try {
do {
That way if this loop stops its work for any reason — in particular an unexpected exception — it'll always clear the flag, so that a future call can still try again.
lib/model/message_list.dart
Outdated
// TODO(log) | ||
return null; | ||
} | ||
return messages.sublist(firstIndex, lastIndex + 1); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is making a new List by copying the given range of elements. But it looks like the one caller immediately consumes it as effectively an Iterable. So this could avoid the copy by instead using List.getRange
.
1f2367b
to
a90d7e0
Compare
Thanks!! Revision pushed. |
lib/model/message.dart
Outdated
/// | ||
/// Returns true if some were added (i.e. weren't already in the queue), | ||
/// false otherwise. | ||
bool add(Iterable<int> messageIds) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: containers like List and Set have methods add
to add one element and addAll
to add from an iterable, so probably clearest to match that naming:
bool add(Iterable<int> messageIds) { | |
bool addAll(Iterable<int> messageIds) { |
lib/model/message_list.dart
Outdated
@@ -222,6 +222,17 @@ mixin _MessageSequence { | |||
return binarySearchByKey(items, messageId, _compareItemToMessageId); | |||
} | |||
|
|||
Iterable<Message>? getMessagesSublist(int firstMessageId, int lastMessageId) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: rename to match new semantics:
Iterable<Message>? getMessagesSublist(int firstMessageId, int lastMessageId) { | |
Iterable<Message>? getMessagesRange(int firstMessageId, int lastMessageId) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
OK, and here's comments on the widgets/message_list.dart changes. All the suggested changes here are NFC.
lib/main.dart
Outdated
|
||
void main() { | ||
assert(() { | ||
debugLogEnabled = true; | ||
MessageListPage.debugEnableMarkReadOnScroll = true; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It doesn't feel great to me to have to set something like this in the main function, vs. having tests disable it.
Trying flutter test
with the default flipped, it looks like there are just four test files with failing tests: autocomplete, action_sheet, compose_box, message_list (all in test/widgets/). So maybe just set it to false at the top of each of those?
If we did want to do it globally across all tests, there's a handy file test/flutter_test_config.dart
for that. That's probably cleaner than here, because it means the default where the flag is declared is the default that applies in the live app.
(Potentially debugLogEnabled
should go there too; I think I added that flag very early and may not have known about flutter_test_config
at the time. But also it bothers me less to have it here than this flag would, because it's not in the user-facing functionality of the app.)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
it looks like there are just four test files with failing tests: autocomplete, action_sheet, compose_box, message_list (all in test/widgets/)
(I also get failures in lightbox and emoji_reaction tests; those are easy to treat the same way)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Cool. Yeah, what I did was:
- Run
flutter test
. - Scan the 158 resulting test failures by eye as they scroll past (each of which is like 20 lines of output, designed to be enough information to actually investigate the failure), and try to spot all the different filenames that appear.
So fairly error-prone.
(I'm not sure flutter test
/ dart test
has a better way out of the box for collecting more of a summary of which tests failed, without all that detail about each failure. There are some verbosity controls, but I think only for turning up the verbosity on non-failing tests.)
lib/widgets/message_list.dart
Outdated
/// This view's decision whether to mark read on scroll, | ||
/// overriding [GlobalSettings.markReadOnScroll]. | ||
/// | ||
/// For example, this is set to false after pressing | ||
/// "Mark as unread from here" in the message action sheet. | ||
bool? get thisViewMarksReadOnScroll; | ||
set thisViewMarksReadOnScroll(bool? value); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Given that this has essentially the same semantics when not null as the global markReadOnScroll
, just overrides the latter, I actually like the idea of giving it the same name markReadOnScroll
.
That way matches the pattern foo ?? theme.foo ?? _defaults.foo
that we use in some other places. It also means fewer names for essentially the same concept.
/// | ||
/// Defaults to false, so we don't have to set to false all over our tests -- | ||
/// but main.dart sets `true` so the feature is enabled when you run the app. | ||
static bool get debugEnableMarkReadOnScroll { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For this one, on the other hand, it's good the name is different (with "enable") — a true
here has quite a different meaning from in the other variables, and in particular true and false are asymmetric.
lib/widgets/message_list.dart
Outdated
if ( | ||
widget.thisViewMarksReadOnScroll | ||
?? GlobalStoreWidget.settingsOf(context).markReadOnScrollForNarrow(widget.narrow) | ||
) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Likely clearer if the logic that gives effect to debugEnableMarkReadOnScroll
is consolidated here too, so they're all next to each other to make their relationships clear.
Perhaps as a method (or getter?) named like _effectiveMarkReadOnScroll
.
lib/widgets/message_list.dart
Outdated
@@ -576,7 +713,44 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat | |||
_prevFetched = model.fetched; | |||
} | |||
|
|||
void _markReadOnScroll() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmmm but possibly confusing that this method (and the one on MessageStore) have the same name as the setting.
I think "on" isn't quite the right preposition here anyway — calling this method is a way of saying "please go and mark read, because scrolling happened", not "please mark read if and when scrolling happens".
Perhaps _markReadFromScroll
? And similarly for the MessageStore method.
lib/widgets/message_list.dart
Outdated
MessageListView get model => _model!; | ||
MessageListView? _model; | ||
|
||
final MessageListScrollController scrollController = MessageListScrollController(); | ||
|
||
final ValueNotifier<bool> _scrollToBottomVisible = ValueNotifier<bool>(false); | ||
|
||
(int, int)? _messagesRecentlyInViewport; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: move this next to _markReadOnScroll, as that's the code that's really responsible for maintaining it (and that consumes it)
/// A message is considered onscreen if its bottom edge is in the viewport. | ||
/// | ||
/// Ignores outbox messages. | ||
(int, int)? _findMessagesInViewport() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: move this and the other new method _isMessageItemInViewport
down below the lifecycle methods, and below their close partner _modelChanged
; I guess that puts them right near _markReadOnScroll
and the existing scroll-handling methods
lib/widgets/message_list.dart
Outdated
} | ||
element.visitChildElements(visit); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: this is equivalent, right?
} | |
element.visitChildElements(visit); | |
default: | |
element.visitChildElements(visit); | |
} |
Then it's more immediately clear that the recursion is only for widgets not among the cases above.
lib/widgets/message_list.dart
Outdated
final widget = element.widget; | ||
|
||
switch (widget) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: these seem closely tied together
final widget = element.widget; | |
switch (widget) { | |
final widget = element.widget; | |
switch (widget) { |
lib/widgets/message_list.dart
Outdated
case MessageItem(): | ||
if (widget.item is! MessageListMessageItem) return; // ignore outbox | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How about:
case MessageItem(): | |
if (widget.item is! MessageListMessageItem) return; // ignore outbox | |
case MessageItem(item: MessageListOutboxMessageItem()): | |
return; // ignore outbox | |
case MessageItem(item: MessageListMessageItem(:final message)): |
Then the later line
final message = (widget.item as MessageListMessageItem).message;
can be dropped.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
OK, and read the settings changes. I think that covers it!
Just one nontrivial comment here (which is still an NFC suggestion), to split the setting to a separate commit.
lib/widgets/settings.dart
Outdated
RadioListTile.adaptive( | ||
title: Text(_valueDisplayName(value, | ||
zulipLocalizations: zulipLocalizations)), | ||
subtitle: () { | ||
final result = _valueDisplayDescription(value, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: "description" seems enough to make "display" redundant:
RadioListTile.adaptive( | |
title: Text(_valueDisplayName(value, | |
zulipLocalizations: zulipLocalizations)), | |
subtitle: () { | |
final result = _valueDisplayDescription(value, | |
RadioListTile.adaptive( | |
title: Text(_valueDisplayName(value, | |
zulipLocalizations: zulipLocalizations)), | |
subtitle: () { | |
final result = _valueDescription(value, |
(by contrast "name" could mean an identifier-style name used in the code, or the name displayed to the user)
@@ -150,6 +151,86 @@ class VisitFirstUnreadSettingPage extends StatelessWidget { | |||
} | |||
} | |||
|
|||
class _MarkReadOnScrollSetting extends StatelessWidget { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this main commit could productively be split in two:
- A final commit adds the setting.
- A preceding commit adds all the logic in model/message and model/message_list and widgets/message_list and so on which the setting will enable, but behaves as if the setting were always false/never.
If we were writing tests for this synchronously, then after the first commit the logic would get exercised by the tests, just never in the live app. Potentially that would require a small extra mechanism, deleted in the second commit, to allow turning the behavior on. (I think not even that, though — the tests could set thisViewMarksReadOnScroll
to true.)
The main value of this split is that the setting is a fair amount of code, and all of a quite different character from most of the rest of the changes.
a90d7e0
to
8b5d0d4
Compare
Thanks! Revision pushed, and it's time for bed for me :) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the revision! Those changes all look good.
One comment below, after I looked a bit more at the addAutomaticKeepAlives
question.
lib/widgets/message_list.dart
Outdated
delegate: SliverChildBuilderDelegate( | ||
// None of the children ever need to keep themselves alive; | ||
// opt out of adding AutomaticKeepAlive widgets that we don't need. | ||
// (This helps keep _findMessagesInViewport efficient.) | ||
addAutomaticKeepAlives: false, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looking into this a bit, I'm nearly convinced we never currently need keep-alive in these list items.
I'm less sure we'll never want it in the future — for example, being able to select text in a message is a repeatedly-requested feature, and selecting text would cause the child to try to keep itself alive. And I'm not entirely sure what the consequences would be of a widget getting discarded after all when it tried to keep itself alive. So this seems like potentially a pitfall for future changes, when we might not be thinking about this feature.
The way I looked for things that would invoke this is: it seems it's all about receiving KeepAliveNotification instances. I looked for call sites of that class's constructor; there's only one, in AutomaticKeepAliveClientMixin. So then I looked for subclasses of that mixin, and there are 5 of them:
_DismissibleState
EditableTextState
_InkResponseState
_SelectionKeepAliveState
_CupertinoTextFieldState
So selection, text fields (plain, Material which uses plain, and Cupertino), InkWell, and Dismissible which is about items the user can swipe out of a list.
Some of those are unlikely to ever appear, like Dismissible. But for InkWell, I'm not 100% sure we don't already have something in the message list that uses it.
When we measured the timing, the walk of the element tree took a very short amount of time even in debug mode. With this flag true (the default), it'll have roughly double as many elements to walk through, but that'll still make a negligible amount of time. So let's leave this commit out for launch.
When we add the setting for this, coming up, this long-awaited feature will become active. Hooray! This still needs tests. We're tracking that as zulip#1583 for early post-launch. (The launch is coming up very soon.)
This still needs tests. We're tracking this as zulip#1583 for early post-launch. (The launch is coming up very soon.) Fixes: zulip#81
8b5d0d4
to
301384c
Compare
And merging! After dropping that |
As zulip/zulip-flutter#1574 was merged for this feature, we add back the instructions for automatically marking messages as read on scroll for the mobile app. Part of zulip#34748.
As zulip/zulip-flutter#1574 was merged for this feature, we add back the instructions for automatically marking messages as read on scroll for the mobile app. Part of zulip#34748.
As zulip/zulip-flutter#1574 was merged for this feature, we add back the instructions for automatically marking messages as read on scroll for the mobile app. Part of zulip#34748.
As zulip/zulip-flutter#1574 was merged for this feature, we add back the instructions for automatically marking messages as read on scroll for the mobile app. Part of zulip#34748.
(edited)
Fixes #81. 🎉
Still to do, but can be soon-after-launch: