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

Unread markers #317

Merged
merged 4 commits into from
Oct 31, 2023
Merged

Unread markers #317

merged 4 commits into from
Oct 31, 2023

Conversation

sirpengi
Copy link
Contributor

@sirpengi sirpengi commented Oct 11, 2023

This PR introduces unread markers with animations whenever the read state changes for a message. Bundled into this PR is also:

  • Associated integration tests and docs on obtaining performance metrics from a physical device that I used to confirm the performance of the animation being used
  • A fix for animation state being lost when the number of items in the message list changes. This involved adding a unique key to every message as well as providing a findChildIndexCallback to StickyHeaderListView.builder.

screenshow_with_new_border

Discussion related to design of this on CZO: https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/flutter.3A.20unread.20marker

Fixes: #79

@sirpengi
Copy link
Contributor Author

@gnprice Would appreciate a review of this while design discussions are still ongoing

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 @sirpengi! The integration tests (or performance profiling) are an interesting new capability for us to have. Comments below.

Comment on lines 364 to 366
// Historically, web handled the left-side unread marker in a funky way:
// box-shadow: inset 3px 0px 0px -1px #c2726a, -1px 0px 0px 0px #c2726a;
// (where the color is the stream color.) That is, it's a pair of
Copy link
Member

Choose a reason for hiding this comment

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

This wasn't the unread marker — it was a recipient marker, as the existing version of the comment says. That is, it's part of indicating what stream is the recipient, or whether the recipient is a set of individuals via DM.

If we're removing the left-side recipient marker that this is talking about, then we can remove the comment at the same time.

Comment on lines 388 to 391
return Positioned(
top: 0,
left: 0,
bottom: 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 makes the interface of this _UnreadMarker widget pretty intimately tied in with the Stack at the call site. (That is, this widget has very specific expectations from its caller, requiring the latter to put it in a Stack.) The cleanest thing is probably to have the Stack and the Positioned appear next to each other in the source.

At the call site that can look like this:

  _UnreadMarker(
    isRead: message.flags.contains(MessageFlag.read),
    child: MessageWithPossibleSender(item: item))

using the idiom of a child property to let the caller still control the underlying message widget, and the unread-marker widget remain focused on the unread marker itself.

child: Container(
width: 4,
decoration: BoxDecoration(
color: const HSLColor.fromAHSL(1, 227, 0.78, 0.59).toColor()))));
Copy link
Member

Choose a reason for hiding this comment

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

Where do these values come from? They don't appear to match any of the colors in the comment above.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It came from the figma mockup, but that didn't seem like something that I could link to and no other place in the code refers to it. I've added a note pointing to the CZO discussion on this topic for this.

Copy link
Member

Choose a reason for hiding this comment

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

Cool, sounds good. It seems like Figma does have some deep-linking capability, but a chat message is probably better to link to anyway because it gives the context of the discussion.

@@ -300,4 +300,47 @@ void main() {
debugNetworkImageHttpClientProvider = null;
});
});

group('unread marker', () {
Copy link
Member

Choose a reason for hiding this comment

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

Tests should be added in the same commit as the code they test

Comment on lines -375 to -376
final recipientBorder = BorderSide(color: highlightBorderColor, width: 3);
final restBorder = BorderSide(color: restBorderColor, width: 1);
Copy link
Member

Choose a reason for hiding this comment

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

msglist: Remove borders around messages and adjust padding

The original implementation here tried to match the
design of the webapp as it existed at the time.
Since then, the design of web has moved on and

This part is true, but…

the design of mobile is expected to continue on the
same direction of having messages in the chat
expand from screen edge to screen edge.

it's not really connected to this part, nor to what this commit (or PR) is doing.

In particular the design on web still has borders around the messages. If we were following the updated web design, we'd have borders, and we'd have a gray background for the overall message list. (That background would show through between one block of messages and the next, and to the left and right of the blocks of messages.)

Relatedly, having the messages extend from edge to edge of the screen isn't something where mobile will be following in a direction set by web. It's something that zulip-mobile has been doing since forever (because it makes sense for a mobile environment with a narrow screen), and that web doesn't do and has no plans to do.

What's happening when we remove the borders and extend from edge to edge is that we're moving from a design based on web — and yes it was based on the pre-2023 design of web, but this aspect would be exactly the same based on web's design today — to a mobile-specific design. And we're doing so by taking a design element found in the zulip-mobile RN app.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think I meant what you outlined above, my statement of "the design of web has moved on" meant "the design the original implementation tried to match isn't even the current design of the web" and on top of that mobile had it's own separate design and is expected to stay separate (and so here again we're not sticking with web but following the mobile design evolution). I do think the language was overly verbose, so I've edited it down and hopefully added clarity to the point being made.

Copy link
Member

Choose a reason for hiding this comment

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

msglist: Messages changed to render edge-to-edge and borderless

This is a small change in line with #157 and
moves the implementation closer to the new 2023+
design for mobile.
Messages now span from screen edge to screen edge
and borders around groups of messages (in particular
the left-side recipient border) was removed.

This is much closer, but the first sentence is still inaccurate. The change this is making is in a direction completely orthogonal to #157 or to any new design for mobile — it's about moving from a design based on web to a mobile-specific design. The key design features here are exactly what zulip-mobile has had for many years.

In the summary line, a nit: verb should be in the imperative (or infinitive): https://zulip.readthedocs.io/en/latest/contributing/commit-discipline.html#commit-summary-part-2

Here's a revised version:

msglist: Go edge-to-edge, for a mobile-oriented design

The existing design here was designed for web; the prototype
copied precisely from web's design as of 2022-12.

But in the zulip-mobile RN app, we've long had a design that's
optimized for a narrow mobile screen instead: notably by using the
full available width (up to a maximum, anyway), with no left or right
borders, and consequently less padding too.  Do that here.

pubspec.lock Outdated
Comment on lines 698 to 702
name: platform
sha256: "4a451831508d7d6ca779f7ac6e212b4023dd5a7d08a27a63da33756410e32b76"
sha256: ae68c7bfcd7383af3629daafb32fb4e8681c7154428da4febcff06200585f102
url: "https://pub.dev"
source: hosted
version: "3.1.0"
version: "3.1.2"
Copy link
Member

Choose a reason for hiding this comment

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

Is this also an unrelated change?

Having unrelated changes mixed in with the intended ones makes the changes harder to review. When sending a PR (or revision), please reread the changes with git log --stat -p or with gitk or equivalent, and look for changes that aren't part of what is intended.

In this case, if some of the changes are introduced automatically by commands like flutter pub get, it's fine to include the change at the start of the branch to simplify managing the rest of the changes — but, like any unrelated changes, it still needs to be a separate commit.

(Ultimately we make such a change in a commit like 790a910, just merged as part of #316. So if you make it in that form, we can merge it as part of the PR; it's also fine to make the changes as just a draft commit, and then it'll disappear when rebased after we merge such an upgrade PR.)

Comment on lines 10 to 12
// Convert the Timeline into a TimelineSummary that's easier to
// read and understand.
final summary = driver.TimelineSummary.summarize(timeline);
Copy link
Member

Choose a reason for hiding this comment

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

This reads like the voice of Flutter upstream, not your voice. Is this code copied from an example somewhere?

I see, yes. A web search on the text finds this:
https://docs.flutter.dev/cookbook/testing/integration/profiling#3-save-the-results-to-disk

Two important general points, then:

  • When copying example code (or anything else) from somewhere, please flag the source clearly, either in the commit message or in a comment next to the copied material.

    This is important for the reviewer to be able to properly review the changes, by understanding how you're making the choices you made.

  • When copying material from somewhere that isn't within the Zulip project, you absolutely must clearly flag that.

    After we merge code into our tree, we distribute it to the world under a free license. We need that to be legal. Generally that means that the code either was contributed to Zulip by its author, or comes under a free license that allows us to include it.

    And that means we have to be able to have confidence that 100% of what's in our tree is material we legally can have there. So when something is copied from elsewhere, we need to be explicit about that. That way I can review the source we're drawing from and confirm that it comes under a compatible free license.

Copy link
Member

Choose a reason for hiding this comment

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

And one that's specific to this file:

  • This comment and the one below it are useful in the cookbook recipe they come from, because they're addressed to the reader of the recipe. But they're largely beside the point here in our tree. I think the one relevant bit is the one about chrome://tracing; and that's really more relevant when running flutter drive with this file, rather than when editing it.

    The main place we discuss running flutter drive in this PR is over in the handy docs file. So that advice can go there, and then I think the relevant information for comments in this file is:

    • a cross-reference to that docs file, for background and for instructions on using the file;
    • a link to that cookbook recipe that the file is based on, for context that may be helpful when editing the file.

await tester.pumpAndSettle();
await tester.pumpAndSettle();
},
reportKey: 'test',
Copy link
Member

Choose a reason for hiding this comment

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

OK, and now that I've read the cookbook recipe: why pass this argument? There's already a default, of timeline, which seems no less informative than calling it test.

As the cookbook says:

Specify the reportKey when running more than one traceAction.

and we're running just one of them here.

in `integration_tests/`.

The integration tests also interact with test driver
code, which a sample exists as `test_driver/perf_driver.dart`.
Copy link
Member

Choose a reason for hiding this comment

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

A "sample" is something that's not necessarily usable by itself, and that one is expected to probably want to adapt when using it. Does that apply here — what adaptations are expected to be needed?

Or on the other hand is this simply the driver we expect to use for performance profiling? (Possibly accumulating some changes over time, naturally — just like any of the rest of our code.)

Comment on lines 34 to 35
writes both the entire timeline as well as a summary
to disk inside the `build` directory.
Copy link
Member

Choose a reason for hiding this comment

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

Writes them where — how does one locate the file (or files?) it writes out?

Probably the best place for that information is just after the sample command line below.

@sirpengi sirpengi force-pushed the pr-unread-markers branch 7 times, most recently from 85243fd to 508f86f Compare October 25, 2023 15:25
@sirpengi sirpengi changed the title [WIP] Unread markers Unread markers Oct 25, 2023
@sirpengi
Copy link
Contributor Author

@gnprice Ready for review! In general we've kept small prep commits at the beginning of the PR chain but I've put two commits more related to the animation bugfix before the fix itself as I think they're more semantically connected. They are quite easily reordered in any case.

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 for the revision! For this round I've read through everything but the tests and the integration tests.

Comment on lines 215 to 217
eg.dmMessage(from: eg.selfUser, to: []),
eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]),
eg.dmMessage(from: eg.thirdUser, to: [eg.selfUser, eg.otherUser]),
eg.dmMessage(id: 1, from: eg.selfUser, to: []),
eg.dmMessage(id: 2, from: eg.otherUser, to: [eg.selfUser]),
eg.dmMessage(id: 3, from: eg.thirdUser, to: [eg.selfUser, eg.otherUser]),
Copy link
Member

Choose a reason for hiding this comment

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

Huh interesting — why are these needed? The commit message says

msglist test [nfc]: Add distinct ids to messages in 'show names on DMs' setup

but it's not clear why these need distinct IDs now and didn't before.

If I leave out this change, I see that this test fails (and also the one after it, awkwardly).

I'm guessing the change that makes this necessary is where you add keys to the message items based on the message IDs.

The commit message should say why the change is needed, because that helps us think through whether it's a pattern that applies more broadly. Here, it seems like we probably need all our tests to start providing distinct message IDs, at least when building a message list. Otherwise, they're all skating on thin ice — even if they happen not to trigger the problems caused by reusing keys for things that are meant to be distinct, they're vulnerable to potentially failing, just like this one, after some minor unrelated change causes them to start doing so.

Copy link
Member

Choose a reason for hiding this comment

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

msglist test [nfc]: Add distinct ids to messages in 'show names on DMs' setup

This will prevent a future test failure when other parts of
the app begin assuming that messages have unique ids.

Cool, that's helpful.

As I suggested above, though, it seems like we probably need to do this more broadly. I've just gone and implemented that, as #343. So you can stack this PR atop that one (even before it's merged), and then this isn't needed.

Similarly #343 will make unnecessary several other uses of id on eg.streamMessage and eg.dmMessage calls in these tests, just like the ones in main now that it removes.

Comment on lines -375 to -376
final recipientBorder = BorderSide(color: highlightBorderColor, width: 3);
final restBorder = BorderSide(color: restBorderColor, width: 1);
Copy link
Member

Choose a reason for hiding this comment

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

msglist: Messages changed to render edge-to-edge and borderless

This is a small change in line with #157 and
moves the implementation closer to the new 2023+
design for mobile.
Messages now span from screen edge to screen edge
and borders around groups of messages (in particular
the left-side recipient border) was removed.

This is much closer, but the first sentence is still inaccurate. The change this is making is in a direction completely orthogonal to #157 or to any new design for mobile — it's about moving from a design based on web to a mobile-specific design. The key design features here are exactly what zulip-mobile has had for many years.

In the summary line, a nit: verb should be in the imperative (or infinitive): https://zulip.readthedocs.io/en/latest/contributing/commit-discipline.html#commit-summary-part-2

Here's a revised version:

msglist: Go edge-to-edge, for a mobile-oriented design

The existing design here was designed for web; the prototype
copied precisely from web's design as of 2022-12.

But in the zulip-mobile RN app, we've long had a design that's
optimized for a narrow mobile screen instead: notably by using the
full available width (up to a maximum, anyway), with no left or right
borders, and consequently less padding too.  Do that here.

// transition using the ease-out curve.
// See zulip-mobile:src/webview/static/base.css .
//
// Modern web uses a block element will a linear-gradient
Copy link
Member

Choose a reason for hiding this comment

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

nit:

Suggested change
// Modern web uses a block element will a linear-gradient
// Modern web uses a block element with a linear-gradient

Copy link
Member

Choose a reason for hiding this comment

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

Also can just say "web" rather than "modern web", as there's no mention of the pre-2023 design to contrast "modern" with. And the description "modern" is doomed to become out of date anyway as time passes and things continue to evolve :-)

Comment on lines 433 to 436
child: Container(
width: 4,
decoration: BoxDecoration(
color: const HSLColor.fromAHSL(1, 227, 0.78, 0.59).toColor())))),
Copy link
Member

Choose a reason for hiding this comment

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

Can reduce this to simpler parts:

Suggested change
child: Container(
width: 4,
decoration: BoxDecoration(
color: const HSLColor.fromAHSL(1, 227, 0.78, 0.59).toColor())))),
child: SizedBox(width: 4,
child: ColoredBox(
color: const HSLColor.fromAHSL(1, 227, 0.78, 0.59).toColor())))),

Also the SizedBox (or Container) can go outside the animated widget. I think that doesn't actually end up mattering for AnimatedOpacity in particular — the thing it does on each frame of an animation is to re-composite layers, not to re-build — but it's easy here, and it's a good habit to default to to push complexity outside of what's animated.

Copy link
Contributor Author

@sirpengi sirpengi Oct 26, 2023

Choose a reason for hiding this comment

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

Even better I've figured out the Positioned widget can also handle the width aspect. Also used ColoredBox instead of a Container as suggested.

}

/// Widget responsible for showing the read status of a message.
// Mobile currently uses a single inset box-shadow:
Copy link
Member

Choose a reason for hiding this comment

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

In the future "mobile currently" will mean this codebase :-) So this should be made more specific: "zulip-mobile uses …", or "The zulip-mobile RN app uses …".

Comment on lines 429 to 436
child: AnimatedOpacity(
opacity: isRead ? 0 : 1,
duration: Duration(milliseconds: isRead ? 2000 : 300),
curve: Curves.easeOut,
child: Container(
width: 4,
decoration: BoxDecoration(
color: const HSLColor.fromAHSL(1, 227, 0.78, 0.59).toColor())))),
Copy link
Member

Choose a reason for hiding this comment

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

Here's a version integrating my other comment, and then putting the key pieces of information about the design into concise comments right at the relevant bits of the code:

Suggested change
child: AnimatedOpacity(
opacity: isRead ? 0 : 1,
duration: Duration(milliseconds: isRead ? 2000 : 300),
curve: Curves.easeOut,
child: Container(
width: 4,
decoration: BoxDecoration(
color: const HSLColor.fromAHSL(1, 227, 0.78, 0.59).toColor())))),
child: SizedBox(width: 4,
child: AnimatedOpacity(
opacity: isRead ? 0 : 1,
// Web uses 2s and 0.3s durations, and a CSS ease-out curve.
// See zulip:web/styles/message_row.css .
duration: Duration(milliseconds: isRead ? 2000 : 300),
curve: Curves.easeOut,
child: ColoredBox(
// The color hsl(227deg 78% 59%) comes from the Figma mockup at:
// https://github.com/zulip/zulip-mobile/issues/5511
// https://www.figma.com/file/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=132-9684
color: const HSLColor.fromAHSL(1, 227, 0.78, 0.59).toColor())))),

Then I think that replaces most of the comment that's at the top of the class.

Copy link
Member

Choose a reason for hiding this comment

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

Ah right or in light of the discussion above at #317 (comment) , the link to that GitHub issue can be replaced with a chat link:
https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/flutter.3A.20unread.20marker/near/1658008

(Best to link to a specific message ID, especially when the thread is already long. That way it's clear which part of the discussion you're referring to.)

The issue is linked from the chat thread, and it doesn't add anything specific about the colors besides the link to Figma, so the issue link can be dropped when the chat link is present.

// both with an ease-out curve.
// See zulip:web/styles/message_row.css .
//
// Current design work is ongoing and values used here come
Copy link
Member

Choose a reason for hiding this comment

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

A remark like "current design work is ongoing" is something that typically won't still be true when we merge a change. So that means it generally doesn't belong in the code of the PR, which is a proposal for code to merge. Instead it's a good remark to make in the PR thread, e.g. in the form of an inline comment (a GitHub "review" comment).

// TODO: Offer `ScrollViewKeyboardDismissBehavior.interactive` (or
// similar) if that is ever offered:
// https://github.com/flutter/flutter/issues/57609#issuecomment-1355340849
findChildIndexCallback: (Key key) {
Copy link
Member

Choose a reason for hiding this comment

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

This comment is about the code it was immediately next to, namely keyboardDismissBehavior. So it should stay attached to that — otherwise the comment starts becoming puzzling to understand, and also someone looking at the relevant code is no longer likely to notice the comment.

// be improved but for now it only triggers for materialized
// widgets. As a simple test, scrolling through All Messages in
// CZO on a Pixel 5, this only runs about 10 times per rebuild
// and the timing for each call is measured in microseconds.
Copy link
Member

Choose a reason for hiding this comment

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

microseconds

Great!

Well, hm — does that mean "a handful of microseconds" or does it mean "less than a millisecond"?

The latter is something people might often mean by "measured in microseconds", and in this context it wouldn't actually be very encouraging — 10 times nearly-a-ms would be nearly 10ms, which would be a real problem. In general "measured in $unit" is not a very clear way of saying how big something is. For example, physicists routinely measure both subatomic and intergalactic distances in meters.

Better to be more concrete: like "<100 microseconds" or "<10 microseconds", or whatever you can most easily measure that's still specific enough to be reassuring.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Specified that it was "<100 microseconds". The total impact would be around 1ms so it's quite reassuring the current behavior will be okay. Also changed "scrolling" to "flinging".

return null;
}
// Note `itemBuilder` pulls from `model.items` in reverse order.
return length - index - 1;
Copy link
Member

Choose a reason for hiding this comment

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

Clearest to group these terms in the same way as in itemBuilder:

Suggested change
return length - index - 1;
return length - 1 - index;

That way when comparing the two, "length - 1" can be visually taken together as a single common quantity with a minimum of mental algebra.

@sirpengi
Copy link
Contributor Author

@gnprice ready for the next round!

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 for the revision! Got to this a bit late in the day, so for this round just went back over what was in the previous round #317 (review) . Small comments below.

width: 4,
child: AnimatedOpacity(
opacity: isRead ? 0 : 1,
// Web used 2s and 0.3s durations, and a CSS ease-out curve.
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
// Web used 2s and 0.3s durations, and a CSS ease-out curve.
// Web uses 2s and 0.3s durations, and a CSS ease-out curve.

Comment on lines 215 to 217
eg.dmMessage(from: eg.selfUser, to: []),
eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]),
eg.dmMessage(from: eg.thirdUser, to: [eg.selfUser, eg.otherUser]),
eg.dmMessage(id: 1, from: eg.selfUser, to: []),
eg.dmMessage(id: 2, from: eg.otherUser, to: [eg.selfUser]),
eg.dmMessage(id: 3, from: eg.thirdUser, to: [eg.selfUser, eg.otherUser]),
Copy link
Member

Choose a reason for hiding this comment

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

msglist test [nfc]: Add distinct ids to messages in 'show names on DMs' setup

This will prevent a future test failure when other parts of
the app begin assuming that messages have unique ids.

Cool, that's helpful.

As I suggested above, though, it seems like we probably need to do this more broadly. I've just gone and implemented that, as #343. So you can stack this PR atop that one (even before it's merged), and then this isn't needed.

Similarly #343 will make unnecessary several other uses of id on eg.streamMessage and eg.dmMessage calls in these tests, just like the ones in main now that it removes.

Comment on lines 270 to 247
},
// TODO: Offer `ScrollViewKeyboardDismissBehavior.interactive` (or
Copy link
Member

Choose a reason for hiding this comment

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

nit:

Suggested change
},
// TODO: Offer `ScrollViewKeyboardDismissBehavior.interactive` (or
},
// TODO: Offer `ScrollViewKeyboardDismissBehavior.interactive` (or

Each of these is a sizable chunk of code, and they're fairly separate from each other in meaning, so the blank line helps visually set them off.

Especially since the latter chunk starts with a comment — the blank line separates it from the unrelated chunk above, helping it stay visually attached to the chunk below.

Comment on lines 268 to 269
// Note `itemBuilder` pulls from `model.items` in reverse order.
return length - 1 - index;
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
// Note `itemBuilder` pulls from `model.items` in reverse order.
return length - 1 - index;
return length - 1 - index;

The corresponding code in itemBuilder isn't far away, and that's the most dependable thing to refer to to understand why this expression is what it is.

Also we should have tests that check this is correct. That's the dependable way to make sure that this and itemBuilder stay in sync. … And at a quick experiment, things look good on that front — if I edit this to return index;, several tests fail.

Copy link
Member

Choose a reason for hiding this comment

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

The corresponding code in itemBuilder isn't far away

Speaking of which, though, a nit: this findChildIndexCallback should go after keyboardDismissBehavior, not before. That way it's adjacent to the stanza that has itemBuilder in it, which is good because as we see their logic is interdependent with each other. And there isn't such an interdependence involving keyboardDismissBehavior.

@sirpengi
Copy link
Contributor Author

@gnprice comments handled, ready again!

@gnprice
Copy link
Member

gnprice commented Oct 27, 2023

Cool, that looks good. Later today I'll plan to review the tests and the integration tests.

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.

OK, and here's from a read through the tests!

Left for another day is the commit adding an integration test:
eadba4e test: Add integration test of _UnreadMarker animation

Comment on lines 316 to 325
check(getAnimation(tester, message.id).value).equals(0);

store.handleEvent(eg.updateMessageFlagsRemoveEvent(
MessageFlag.read, [message]));
await tester.pump(); // process handleEvent
check(getAnimation(tester, message.id).status).equals(AnimationStatus.forward);

await tester.pumpAndSettle();

check(getAnimation(tester, message.id).value).equals(1.0);
Copy link
Member

Choose a reason for hiding this comment

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

It's generally best if the "before" and "after" checks in a test inspect the before and after of the same data — that way it's easy for the reader to see that the test's checks definitely mean something changed.

Specifically here, if we're checking the status is forward after we start things, it's good to check its value before we start things too. Otherwise, maybe forward is just what it is from the beginning.

The value is good to check too — at the middle stage, we could check it's more than 0 and less than 1.

final message = eg.streamMessage(flags: [MessageFlag.read]);
await setupMessageListPage(tester, messages: [message]);

check(getAnimation(tester, message.id).value).equals(0);
Copy link
Member

Choose a reason for hiding this comment

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

To put this in idiomatic package:checks style, we should add handling for Animation in test/flutter_checks.dart.

That will be especially helpful for checking several things on a single value, like its value and its status.

Comment on lines 323 to 325
await tester.pumpAndSettle();

check(getAnimation(tester, message.id).value).equals(1.0);
Copy link
Member

Choose a reason for hiding this comment

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

nit:

Suggested change
await tester.pumpAndSettle();
check(getAnimation(tester, message.id).value).equals(1.0);
await tester.pumpAndSettle();
check(getAnimation(tester, message.id).value).equals(1.0);

Generally I often find it helpful to separate one "scene" of a test from the next: make some changes, check the new state of things, then a blank line before the next round of changes. But the checks go together logically with the changes that lead to them.

final message = eg.streamMessage(flags: []);
await setupMessageListPage(tester, messages: [message]);

check(getAnimation(tester, message.id).value).equals(1.0);
Copy link
Member

Choose a reason for hiding this comment

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

nit: this and the zero in the other check are parallel semantically, so match them stylistically — both written with ".0", or neither.

Otherwise it looks like there's some distinction being intentionally made.

Comment on lines 349 to 350
// Check that _UnreadMarker maintains its in-progress animation
// as the number of items change in MessageList.
Copy link
Member

Choose a reason for hiding this comment

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

nit:

Suggested change
// Check that _UnreadMarker maintains its in-progress animation
// as the number of items change in MessageList.
// Check that _UnreadMarker maintains its in-progress animation
// as the number of items changes in MessageList.

Comment on lines 378 to 379
// If we introduce an animation bug (remove findChildIndexCallback
// on StickyHeaderListView.builder) this returns 1.
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 already true of the check above, right? I.e. that if we introduce the same bug, that check fails.

Copy link
Member

Choose a reason for hiding this comment

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

In general it's expected that if we remove some logic, then some test we added in the same commit will fail.

It is helpful for the reader of this test to have a reference to the name findChildIndexCallback, to see what code it's meant to test. The best place for that is in a comment at the top of the test, or in the name of the test.

Comment on lines 305 to 313
Animation getAnimation(WidgetTester tester, int messageId) {
final widget = tester.widget<FadeTransition>(find.descendant(
of: find.byKey(ValueKey(messageId)),
matching: find.byType(FadeTransition)));
return widget.opacity;
Copy link
Member

Choose a reason for hiding this comment

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

This feels kind of brittle. But I don't have a better suggestion off the top of my head, and I'll be OK with merging it. If we play with it we might be able to find something better.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I agree, it's unfortunate that the AnimatedOpacity widget that is being used in the tree doesn't give us access to the live values and if flutter internally changes how the widget is implemented this would break. Also, if there are further animations added into the MessageItem widget (perhaps we have animations on the reactions, or another UI element in each message) this will likely find the wrong one. I've tried playing with it but haven't found anything that isn't equally brittle.

@@ -300,4 +300,87 @@ void main() {
debugNetworkImageHttpClientProvider = null;
});
});

group('_UnreadMarker animations', () {
Animation getAnimation(WidgetTester tester, int messageId) {
Copy link
Member

Choose a reason for hiding this comment

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

This last commit:

30cb3cd msglist test [nfc]: Pull out getAnimation helper

has the feeling that within the branch an early commit went one direction, then a later commit decided a different direction was better and went that way instead.

Better to revise the branch as a whole to give it a coherent narrative. That means squashing that commit into the one that originally introduces its own version of getAnimation:
8605d56 msglist: Add animated marker for unread messages

Then this later commit:
765de16 msglist: Maintain _UnreadMarker animation state when MessageListView.items changes size

has no need to introduce an alternate version, and there's no need for the third commit moving the alternate version to replace the original version.

pubspec.yaml Outdated
Comment on lines 65 to 67
dev_dependencies:
flutter_driver:
sdk: flutter
Copy link
Member

Choose a reason for hiding this comment

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

deps: Add integration_test and flutter_driver, in preparation for integration tests

Following the style of past commits in this section, it's helpful to flag that these are only dev dependencies (and not dependencies that go into the published app):

deps: Add dev:integration_test, dev:flutter_driver

Cf the other previous commits that added entries here:
381c802 deps: Add dev:fake_async
483a3ef deps: Add drift, sqlite3_flutter_libs, path_provider, path, dev:drift_dev
7a9bf81 deps: Add dev:checks
6c9b94c deps: Add dev:test
9ea09ce deps: Add json_annotation, dev:json_serializable, dev:build_runner

I found those with git log -L 65,85:pubspec.yaml. (Where the line range corresponds to the dev_dependencies: section. There are fancier ways to write git log -L ranges in terms of regexps, which could make the command robust to different versions of the file; but in ad hoc interactive use, I'd just read off the line numbers from looking at the file in my editor.)

The explanation about why we're adding these packages can then go in the body of the commit message. Though in this instance I'd be happy to leave it out — one of the package names already tells the reader it's about integration tests.

Copy link
Member

Choose a reason for hiding this comment

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

BTW the dev: notation there is from the syntax accepted by flutter pub add (or dart pub add). See flutter pub add --help.

@terpimost
Copy link

After some closer look I think we should do slight modification:
image

Clarifications
In the dark mode the blue bar is lighter than background and lighter than device screen border:
image

In the light mode the bar is darker than background, closer to the darkness of device screen border (black)
So the contrast is lower and it is hard to see such bar:
image

I suggest to put 1 px 60% white on top of such bar in the light mode:
image

This gives a better distinction for the light mode unread markers:
image

@gnprice
Copy link
Member

gnprice commented Oct 30, 2023

In the light mode the bar is darker than background, closer to the darkness of device screen border (black) So the contrast is lower and it is hard to see such bar: […]
I suggest to put 1 px 60% white on top of such bar in the light mode:

Great, thanks! That makes sense and I'll be interested to see it.

(We don't yet have dark mode in the Flutter app — that's #95 and is slated for the beta. @sirpengi for this PR, let's leave a TODO(#95) comment saying that extra 1px bar won't be there in dark mode, pointing to @terpimost's comment above. That way it'll be easy for us to see how to handle it when we sweep through for #95.)

The existing design here was designed for web; the prototype
copied precisely from web's design as of 2022-12.

But in the zulip-mobile RN app, we've long had a design that's
optimized for a narrow mobile screen instead: notably by using the
full available width (up to a maximum, anyway), with no left or right
borders, and consequently less padding too.  Do that here.
@sirpengi sirpengi force-pushed the pr-unread-markers branch 2 times, most recently from bdba1f6 to 743cd2c Compare October 30, 2023 20:34
@sirpengi
Copy link
Contributor Author

Added the 1px 60% white border as specified and updated the screenshow in the top comment as well

@sirpengi
Copy link
Contributor Author

@gnprice comments handled, and the design change from @terpimost included too!

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 for the revision!

Leaving out for the moment the commit adding the integration test, this all looks good modulo just a couple of nits below.

I'll take a look at that integration-test commit next. But probably the easiest workflow will be if we split that out, along with its prep commit:
8ff65f4 deps: Add dev:flutter_driver, dev:integration_test
00a6449 test: Add integration test of _UnreadMarker animation
to a separate followup PR.

@@ -286,6 +304,7 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
header: header, child: header);
case MessageListMessageItem():
return MessageItem(
key: ValueKey(data.message.id),
Copy link
Member

Choose a reason for hiding this comment

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

This now appears in a different commit from findChildIndexCallback. The two of them work in tandem and make most sense to understand together, so they should appear in the same commit.

Copy link
Member

Choose a reason for hiding this comment

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

Hmm I see, this commit uses it in tests.

As I mentioned in chat, I'd be hesitant to add keys just for the sake of tests. Here it's OK because we'll be using it in the app code just a few commits later; so we should just make that clear in the commit message. Can add something like "Also add a key to MessageItem widgets in the message list; we'll want those in an upcoming commit that passes findChildIndexCallback, so we just bring them forward to here for convenience in tests."

Comment on lines 412 to 413
child: Container(
decoration: BoxDecoration(
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
child: Container(
decoration: BoxDecoration(
child: DecoratedBox(
decoration: BoxDecoration(

That's what this boils down to; see the implementation of Container.build.

Comment on lines +339 to +341
check(getAnimation(tester, message.id))
..value.equals(1.0)
..status.equals(AnimationStatus.dismissed);
Copy link
Member

Choose a reason for hiding this comment

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

Cool — this extra detail in fact serves as helpful color on how Animation works, how the status interacts with the value. Here the value is at 1 while the status is dismissed, whereas in the read->unread case it was at 0 when dismissed and 1 corresponded to completed.

Comment on lines +381 to +387

// run animation partially
await tester.pump(const Duration(milliseconds: 30));
check(getAnimation(tester, message.id))
..value.isGreaterThan(0.0)
..value.isLessThan(1.0)
..status.equals(AnimationStatus.forward);
Copy link
Member

Choose a reason for hiding this comment

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

Cool, this test is now all nice and clear, with a crisp narrative.

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.

OK, and here's various small comments about the integration tests. This has also gotten much closer!

Comment on lines 3 to 4
Integration tests in Flutter allow self-driven end-to-end
testing of app code running with the full GUI.
Copy link
Member

Choose a reason for hiding this comment

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

What does "self-driven" mean?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

In this case it's as a vehicle driving itself without human interaction. It's the same language the integration_test package uses to describe itself: https://github.com/flutter/flutter/tree/master/packages/integration_test (This package enables self-driving testing of Flutter code on devices and emulators.). I think "autonomous" might be an alternative but I think the "drive" verb is used often in similar contexts (Selenium describes this automation as driving).

Comment on lines 6 to 7
The focus in this document are notes for using integration
tests to capture performance metrics on physical devices.
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
The focus in this document are notes for using integration
tests to capture performance metrics on physical devices.
This document is about using integration
tests to capture performance metrics on physical devices.

Comment on lines 15 to 17
[flutter-docs]: https://docs.flutter.dev/testing/integration-tests

## Capturing performance metrics
Copy link
Member

Choose a reason for hiding this comment

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

nit: two blank lines before Markdown headings, unless preceded by a parent heading or the start of the file:

Suggested change
[flutter-docs]: https://docs.flutter.dev/testing/integration-tests
## Capturing performance metrics
[flutter-docs]: https://docs.flutter.dev/testing/integration-tests
## Capturing performance metrics

I find that helpful for giving a visual hierarchy when reading the Markdown source file.


## Obtaining performance metrics

First, obtain a `device_id` using `flutter devices`.
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
First, obtain a `device_id` using `flutter devices`.
First, obtain a device ID using `flutter devices`.

The string device_id isn't something that appears verbatim in any API or other form of code this is talking about, so doesn't belong in code font.

(It does appear in your example command below, so putting it in code font would make sense if it were clearly referring to that. But when it's here before that command, simpler to just use the English phrase.)

Comment on lines 43 to 45
$ flutter drive \
--driver=integration_test/perf_driver.dart \
--target=integration_test/unreadmarker_test.dart \
Copy link
Member

Choose a reason for hiding this comment

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

nit: indent arguments deeper than the start of the command:

Suggested change
$ flutter drive \
--driver=integration_test/perf_driver.dart \
--target=integration_test/unreadmarker_test.dart \
$ flutter drive \
--driver=integration_test/perf_driver.dart \
--target=integration_test/unreadmarker_test.dart \

(as in #317 (comment) above)

Comment on lines 51 to 56
A data file with raw event timings will be produced in
`build/trace_output.timeline.json`.

A more readily consumable file will also be produced in
`build/trace_output.timeline_summary.json`. This file
contains widget build and render timing data.
Copy link
Member

Choose a reason for hiding this comment

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

Cool. What does one do next with these files?

We don't need to write a lot of text here — a link to whatever resource you found helpful would be great.

And if there was no such resource and you just inspected them manually, then we can say just that — that's still useful to the reader, by letting them know there isn't information that we had before and they're missing.

Comment on lines 49 to 50
// short pause for buffer during video-capture
await tester.pump(const Duration(seconds: 2));
Copy link
Member

Choose a reason for hiding this comment

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

Interesting — what's the video-capture about? Is that involved in the performance test?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Removing this for now. I also used this integration test to automate the app for video recording purposes, and it was useful to have a bit of buffer before the action started, but that isn't relevant to a performance test.

Comment on lines 46 to 47
testWidgets('_UnreadMarker animation performance test', (tester) async {
final messages = await setupMessageListPage(tester, 500);
Copy link
Member

Choose a reason for hiding this comment

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

I think a short comment setting the context here would be helpful. Otherwise it's a bit puzzling how this "test" doesn't appear to actually check anything as it goes.

Suggested change
testWidgets('_UnreadMarker animation performance test', (tester) async {
final messages = await setupMessageListPage(tester, 500);
testWidgets('_UnreadMarker animation performance test', (tester) async {
// This integration test is meant for measuring performance.
// See docs/integration_test.md for how to use it.
final messages = await setupMessageListPage(tester, 500);

Future<void> main() {
return integrationDriver(
responseDataCallback: (data) async {
if (data != null) {
Copy link
Member

Choose a reason for hiding this comment

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

nit: let's de-indent by using an early return:

Suggested change
if (data != null) {
if (data == null) return;

@@ -0,0 +1,24 @@
// This file is derived from BSD licensed example code in
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 this part (about being derived from some other code, and the license of that code) in the commit message rather than an in-tree comment. We've now modified this file a fair bit, and may modify it more in the future; so that's the form that will best avoid getting outdated with such changes.

The link to the docs is still good as context for someone editing this file in the future.

Adds a new _UnreadMarker that renders as a 4px solid
border on the left edge of messages.

Also add a key to MessageItem widgets in the message-list;
we'll want those in an upcoming commit that passes
findChildIndexCallback, so we just bring them forward to
here for convenience in tests.

Fixes: zulip#79
@sirpengi
Copy link
Contributor Author

@gnprice split off the integration tests bit to #349 as well as dealt with the other comments.

@gnprice
Copy link
Member

gnprice commented Oct 31, 2023

Thanks @sirpengi for the revision, and for all your work on this! Looks good; merging. I'll take a look at #349 next.

@gnprice gnprice merged commit 33dcbf9 into zulip:main Oct 31, 2023
1 check passed
@sirpengi sirpengi deleted the pr-unread-markers branch January 23, 2024 14:17
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.

Show unread markers in message list
3 participants