Skip to content
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

Mark as read #364

Merged
merged 4 commits into from
Nov 9, 2023
Merged

Mark as read #364

merged 4 commits into from
Nov 9, 2023

Conversation

sirpengi
Copy link
Contributor

@sirpengi sirpengi commented Nov 2, 2023

This is proceeded by #361 and #363, which both provide api routes necessary for functionality here. This implements the manual "Mark as Read" button that allows the user to explicitly mark messages in their current narrow as read.

This series of PRs fixes: #130


Design made to match figma spec at https://www.figma.com/file/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?type=design&node-id=132-9684&mode=design&t=jJwHzloKJ0TMOG4M-0

mark_as_read_figma

mark_as_unread_updated

Copy link
Member

@gnprice gnprice left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! Here's from a first pass, just on the commits that are new in this PR:
33c0f92 unreads: Add proposal for countInNarrow
0731b86 msglist: Add MarkAsRead widget

I believe the four previous commits:
53de4c2 api [nfc]: Factor out logic that supports serializing ApiNarrowDm
fc69d26 api [nfc]: Pull out a method Anchor.toJson
4d2c4bd api: Add updateMessageFlags and updateMessageFlagsForNarrow routes
f09b8d9 api: Add markAllAsRead, markStreamAsRead, and markTopicAsRead routes

are from other PRs. (The PR description should mention those — just say this one is stacked atop those.)

Comment on lines 130 to 131
case DmNarrow():
return dms[narrow]?.length ?? 0;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should reuse countInDmNarrow.

Then let's also pull out the other cases as their own methods, along the line I described here:
#334 (comment)

Specifically probably like int countInTopicNarrow(int streamId, String topic) is most convenient. (The case of DM narrows is unusual in that even when you know you're talking about a DM narrow, the DmNarrow value itself is the most convenient way to identify it.)

That way other call sites that know what kind of count they want can be direct about it, just like the recent-DMs view is in #334.

Comment on lines 137 to 138
for (final streamData in streams.values) {
for (final model in streamData.values) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
for (final streamData in streams.values) {
for (final model in streamData.values) {
for (final topics in streams.values) {
for (final messageIds in topics.values) {

to match the naming used elsewhere in this class (look for use sites of streams to find examples)

Comment on lines 153 to 155
if (streamData == null) return 0;
final model = streamData[narrow.topic];
return model?.length ?? 0;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can use ?[] similarly to ?.:

Suggested change
if (streamData == null) return 0;
final model = streamData[narrow.topic];
return model?.length ?? 0;
return streamData?[narrow.topic]?.length ?? 0;

(plus the renaming mentioned above)

Comment on lines 706 to 708
Future<void> markNarrowAsRead(BuildContext context, ApiConnection connection, Narrow narrow) async {
final zulipLocalizations = ZulipLocalizations.of(context);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is based on some code we have elsewhere, so links to that code will be helpful for review. We also may want to keep the UX aligned with the one in web, so the comparison may be useful for making future changes too — so the links will be helpful to put in the code itself, rather than the commit message. So like:

Suggested change
Future<void> markNarrowAsRead(BuildContext context, ApiConnection connection, Narrow narrow) async {
final zulipLocalizations = ZulipLocalizations.of(context);
Future<void> markNarrowAsRead(BuildContext context, ApiConnection connection, Narrow narrow) async {
// Compare web's `FUNCTION` in web/src/FILENAME.js
// and zulip-mobile's `FUNCTION` in src/action-sheets/index.js .
final zulipLocalizations = ZulipLocalizations.of(context);

while (true) {
final result = await updateMessageFlagsForNarrow(connection,
anchor: anchor,
// anchor="oldest" is an anchor ID lower than any valid
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"[AnchorCode.oldest]" — 'anchor="oldest"' doesn't mean much in a Dart context

(For that matter it doesn't mean much in a JS context — it looks like whoever wrote the JS comment you're copying here was thinking in Python.)

Comment on lines 354 to 357
class MarkAsReadWidget extends StatelessWidget {
const MarkAsReadWidget({super.key, required this.model});

final MessageListView model;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, does this really want a MessageListView? I'd generally like to let that remain in the hands of the _MessageListState — that's the element for which the MessageListView is the view-model.

It looks like this widget only consults model.narrow and model.store. So let's instead just pass it the narrow, and it can get the store in the usual way.

if (!context.mounted) return;
final narrow = model.narrow;
final connection = model.store.connection;
if (connection.zulipFeatureLevel! >= 155) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should be marked with a todo-server comment

For background and motivation on those comments, see here: https://github.com/zulip/zulip-mobile/blob/main/docs/style.md#todo-server
though the formatting has evolved slightly since then; see examples in this codebase.

},
"markAsReadInProgress": "Marking messages as read...",
"@markAsReadInProgress": {
"description": "SnackBar message when marking messages as read"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These descriptions are for translators, not people working in this codebase. So references to identifiers within our code, or Flutter's API or the like, will be mystifying and don't belong.

This one could be "Progress message while […]".

@sirpengi sirpengi force-pushed the pr-mark-as-read branch 5 times, most recently from 3c763fa to 6074c2a Compare November 7, 2023 11:20
@sirpengi sirpengi marked this pull request as ready for review November 7, 2023 18:28
@sirpengi sirpengi changed the title Draft: Mark as read Mark as read Nov 7, 2023
@sirpengi
Copy link
Contributor Author

sirpengi commented Nov 7, 2023

@gnprice ready for review now!

Copy link
Member

@gnprice gnprice left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! Generally this looks good. Comments below — notably we'll want tests on those countInFoo methods, and there's an important optimization web has that we'll need here.

}

// TODO(#346): account for muted topics and streams
int countInStreamNarrow(StreamNarrow narrow) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
int countInStreamNarrow(StreamNarrow narrow) {
int countInStreamNarrow(int streamId) {

cf #364 (comment)

// TODO(#346): account for muted topics and streams
int countInAllMessagesNarrow() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's add another TODO comment for #370, which I just filed:

Suggested change
// TODO(#346): account for muted topics and streams
int countInAllMessagesNarrow() {
// TODO(#346): account for muted topics and streams
// TODO(#370): maintain this count incrementally, rather than recomputing from scratch
int countInAllMessagesNarrow() {

and same on countInStreamNarrow. That will answer a concern the reader may naturally think of, that this looks inefficient.

Comment on lines 161 to 165
case DmNarrow():
return countInDmNarrow(narrow);
case AllMessagesNarrow():
return countInAllMessagesNarrow();
case StreamNarrow():
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's put these in logical order, so e.g. AllMessagesNarrow at the beginning or end. The same order as in their declarations would be good.

Comment on lines +129 to +135
int countInAllMessagesNarrow() {
int c = 0;
for (final messageIds in dms.values) {
c = c + messageIds.length;
}
for (final topics in streams.values) {
for (final messageIds in topics.values) {
c = c + messageIds.length;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These should get tests.

They can be pretty simple, using the existing helpers in unreads_test.dart: prepare, fillMessages, then check(model.countIn…).equals(3) or whatever.

In principle I guess we should have added a test for countInDmNarrow when we added that method, oops. That'll be easy to add now, though.

@@ -286,7 +290,9 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
// TODO on new message when scrolled up, anchor scroll to what's in view
reverse: true,
itemBuilder: (context, i) {
final data = model!.items[length - 1 - i];
if (i == 0) return MarkAsReadWidget(narrow: model!.narrow);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (i == 0) return MarkAsReadWidget(narrow: model!.narrow);
if (i == 0) return MarkAsReadWidget(narrow: widget.narrow);

Comment on lines 753 to 756
// We previously showed an in-progress [SnackBar], so say we're done.
// There may be a backlog of [SnackBar]s accumulated in the queue
// so be sure to clear them out here.
ScaffoldMessenger.of(context)
..clearSnackBars()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm interesting.

That backlog would mean we're showing an out-of-date amount of progress, right? In that case, when we show a new snack bar here perhaps we should be removing the old one first. I.e. with removeCurrentSnackBar, or perhaps hideCurrentSnackBar.

(The "snack bar" system doesn't just automatically do that all the time, because the user may not be done reading the old snack bar and there's no guarantee the new one has anything to do with it. But here, the new subsumes the old.)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These snackbars don't have any progress information on them, so all their messages are the same ("Marking messages as read..."). I've added more documentation explaining why the queue isn't cleared when a new snack bar is shown, and refactored the messenger access to allow clearing when we lose the context. In short, the reason is clearing the snack bar on each pass results in quick flickering of the popups which obscures the message. It feels better to let the items run through their duration (less hectic energy) and ensure we clear them later.

The snackbar api isn't great, there's no easy way to track the state of each message. An alternative here is to only show one snackbar on the first pass and give it an outrageous duration - but we still have the same issue of needing to clear it when the context disappears.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool, makes sense. This seems good enough, then, until someday in the future we decide it's time to polish this with some UI that's more in the nature of a progress indicator.

if (!context.mounted) return;
final zulipLocalizations = ZulipLocalizations.of(context);
showErrorDialog(context: context,
title: zulipLocalizations.errorDialogTitle,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
title: zulipLocalizations.errorDialogTitle,
title: zulipLocalizations.errorMarkAsReadFailedTitle,

// for more responsive feedback. See zulip@f0d87fcf6.
numBefore: 0,
numAfter: 1000,
narrow: narrow.apiEncode(),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Web has the following optimization, for the mark-all-as-read case:

        // Since there's a database index on is:unread, it's a fast
        // search query and thus worth including here as an optimization.
        narrow: JSON.stringify([{operator: "is", operand: "unread", negated: false}]),

That's important when there's a ton of already-read history — as there may very often be for the "all" case or the stream case. So we should do the same.

The way to express that is with another ApiNarrowElement subclass. Say simply ApiNarrowIsUnread. Can go right above ApiNarrowMessageId, after all the other ApiNarrowElement subclasses.

(Later we can later generalize that to an ApiNarrowIs where the operand is some appropriate enum. But that's a part of the API that's only sort of documented; so that'll involve some reverse-engineering, not a pure matter of transcribing API docs, and we can leave it for another time.)

Comment on lines 781 to 785
// around continuously until the task ends. But we don't have an
// off-the-shelf way to wire up such a thing, and marking a giant
// number of messages unread isn't a common enough flow to be worth
// substantial effort on UI polish. So for now, we use a SnackBar,
// even though they may feel a bit janky.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// around continuously until the task ends. But we don't have an
// off-the-shelf way to wire up such a thing, and marking a giant
// number of messages unread isn't a common enough flow to be worth
// substantial effort on UI polish. So for now, we use a SnackBar,
// even though they may feel a bit janky.
// around continuously until the task ends. For now we use
// a series of [SnackBar]s, which may feel a bit janky.

The justification here is true of marking unread but I think it's less convincing for marking as read, which is what this function is doing.

responseCount++;
updatedCount += result.updatedCount;

if (result.foundNewest) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, web has an interesting wrinkle here:

                if (unread.old_unreads_missing) {
                    // In the rare case that the user had more than
                    // 50K total unreads on the server, the client
                    // won't have known about all of them; this was
                    // communicated to the client via
                    // unread.old_unreads_missing.
                    //
                    // However, since we know we just marked
                    // **everything** as read, we know that we now
                    // have a correct data set of unreads.
                    unread.clear_old_unreads_missing();

Seems right. I'd be fine with just leaving a TODO for it, though.

Comment on lines 799 to 804
case StreamNarrow(:final streamId):
await markStreamAsRead(connection,
streamId: streamId);
case TopicNarrow(:final streamId, :final topic):
await markTopicAsRead(connection,
streamId: streamId, topicName: topic);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
case StreamNarrow(:final streamId):
await markStreamAsRead(connection,
streamId: streamId);
case TopicNarrow(:final streamId, :final topic):
await markTopicAsRead(connection,
streamId: streamId, topicName: topic);
case StreamNarrow(:final streamId):
await markStreamAsRead(connection, streamId: streamId);
case TopicNarrow(:final streamId, :final topic):
await markTopicAsRead(connection, streamId: streamId, topicName: topic);

Comment on lines 753 to 756
// We previously showed an in-progress [SnackBar], so say we're done.
// There may be a backlog of [SnackBar]s accumulated in the queue
// so be sure to clear them out here.
ScaffoldMessenger.of(context)
..clearSnackBars()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool, makes sense. This seems good enough, then, until someday in the future we decide it's time to polish this with some UI that's more in the nature of a progress indicator.

@gnprice
Copy link
Member

gnprice commented Nov 9, 2023

Thanks @sirpengi for the revision! All looks good except one nit above. I'll just fix that and merge.

@gnprice gnprice merged commit e7fe06c into zulip:main Nov 9, 2023
1 check passed
@gnprice
Copy link
Member

gnprice commented Nov 9, 2023

And merged. Fixed one other nit:

78adb13 narrow: Add ApiNarrowIsUnread

The change this is making is within the API bindings (lib/api/), so I prefer the prefix "api:".

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Manually mark as read
2 participants