Skip to content

lightbox: Prevent hero animation between message lists #1348

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

Merged
merged 2 commits into from
May 1, 2025

Conversation

lakshya1goel
Copy link
Contributor

@lakshya1goel lakshya1goel commented Feb 12, 2025

Fixes #930.
Fixes #44.

Videos

Before

WhatsApp.Video.2025-02-12.at.5.18.39.PM.mp4

After

WhatsApp.Video.2025-02-12.at.5.18.35.PM.mp4

@lakshya1goel lakshya1goel marked this pull request as draft February 12, 2025 13:36
@lakshya1goel lakshya1goel marked this pull request as ready for review February 15, 2025 16:01
@lakshya1goel lakshya1goel changed the title msglist: Prevent hero animation between message lists lightbox: Prevent hero animation between message lists Feb 15, 2025
@gnprice
Copy link
Member

gnprice commented Feb 15, 2025

Thanks! Chat thread here:
https://chat.zulip.org/#narrow/channel/516-mobile-dev-help/topic/Hero.20animation/near/2092791
with comments particularly on how to write a good test for this fix.

@PIG208 PIG208 self-assigned this Feb 18, 2025
@PIG208 PIG208 added the maintainer review PR ready for review by Zulip maintainers label Feb 18, 2025
@PIG208 PIG208 self-requested a review February 21, 2025 21:55
@lakshya1goel
Copy link
Contributor Author

lakshya1goel commented Feb 22, 2025

Hi @PIG208, PR is ready for a review now, PTAL, Thanks!
Related CZO Discussion

Copy link
Member

@PIG208 PIG208 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 fixing this! The approach looks fine to me. Left a comment on the implementation and some more on the tests.

@@ -21,20 +21,22 @@ import 'store.dart';
// fly to an image preview with a different URL, following a message edit
// while the lightbox was open.
class _LightboxHeroTag {
_LightboxHeroTag({required this.messageId, required this.src});
_LightboxHeroTag({required this.messageId, required this.src, required this.pageContext});
Copy link
Member

Choose a reason for hiding this comment

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

nit: Wrap this because the parameter list gets a bit too wide.


@override
Widget build(BuildContext context) {
return Hero(
tag: _LightboxHeroTag(messageId: message.id, src: src),
tag: _LightboxHeroTag(messageId: message.id, src: src, pageContext: pageContext),
Copy link
Member

Choose a reason for hiding this comment

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

Do things go wrong if we get the page context from PageRoot.contextOf(context) here? If that works we don't need to pass it around.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Things would go wrong since using PageRoot.contextOf(context) here would break the hero animation because the widget exists in two different page contexts simultaneously in the MessageListPage and the lightbox. By explicitly passing the source page's context (pageContext), we ensure both the source and destination heroes use the same context value in their tags, allowing Flutter to properly match them for the animation.

Copy link
Member

Choose a reason for hiding this comment

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

Indeed. Thanks for the explanation.

Narrow narrow = const CombinedFeedNarrow(),
List<Message>? messages,
List<ZulipStream>? streams,

Copy link
Member

Choose a reason for hiding this comment

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

nit: extra empty line

Comment on lines 248 to 230
// ZulipApp instead of TestZulipApp because we need:
// 1. The navigator to push the lightbox route. The lightbox page works
// together with the route; it takes the route's entrance animation.
// 2. The PageRoot widget to provide context for Hero animations between
// the message list and lightbox.
await tester.pumpWidget(PageRoot(
child: const ZulipApp()
));
await tester.pump();
final navigator = await ZulipApp.navigator;
unawaited(navigator.push(getImageLightboxRoute(
accountId: eg.selfAccount.id,
pageContext: PageRoot.contextOf(navigator.context),
Copy link
Member

Choose a reason for hiding this comment

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

We should be able to skip these changes if not having pageContext as a parameter works.

Copy link
Contributor Author

@lakshya1goel lakshya1goel Feb 27, 2025

Choose a reason for hiding this comment

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

I think we can't skip passing pageContext because during the hero animation, the transitioning widget lives in Flutter's overlay layer outside our PageRoot hierarchy. The hero animation needs the same tag value at both ends (source and destination) to know which widgets to animate between, and since we can't get the PageRoot context during the transition, we must pass it explicitly.


group('LightboxHero', () {
testWidgets('no hero animation occurs between different message list pages for same image', (tester) async {
final channel = eg.stream(streamId: eg.defaultStreamMessageStreamId);
Copy link
Member

Choose a reason for hiding this comment

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

Since the value of streamId is not used later, we can skip specifying. Using the default value should help keep the setup boring and bring focus to what's more interesting.

await tester.pumpAndSettle();
});
});
}
Copy link
Member

Choose a reason for hiding this comment

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

Need a newline at the end of the file.

Comment on lines 623 to 617
await tester.pump(const Duration(milliseconds: 150));

final imageInTransition = tester.getRect(imageFinder);
check(imageInTransition.top).equals(initialImageRect.top);
check(imageInTransition.left).equals(initialImageRect.left);
await tester.pumpAndSettle();
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 supposed to be mid transition time? While this works, I feel that it can miss bugs if the duration somehow changes.

We can have a while-loop that checks tester.hasRunningAnimations, which pumps repeatedly with a fixed duration, and repeats the check:

Suggested change
await tester.pump(const Duration(milliseconds: 150));
final imageInTransition = tester.getRect(imageFinder);
check(imageInTransition.top).equals(initialImageRect.top);
check(imageInTransition.left).equals(initialImageRect.left);
await tester.pumpAndSettle();
int timeElapsed = 0;
const interval = 50;
while (timeElapsed < interval || tester.hasRunningAnimations) {
final imageInTransition = tester.getRect(imageFinder);
check(imageInTransition.top).equals(initialImageRect.top);
check(imageInTransition.left).equals(initialImageRect.left);
await tester.pump(const Duration(milliseconds: interval));
timeElapsed += interval;
}
check(timeElapsed).isGreaterOrEqual(interval);

Copy link
Member

@PIG208 PIG208 Feb 25, 2025

Choose a reason for hiding this comment

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

Just saw the CZO comment:

In order to have that benefit, it's important to keep the two test cases similar to each other as far as possible. It should be easy for the reader to convince themself that the two tests are checking the same thing, and just expecting opposite outcomes.

I agree that having these two tests similar to each other does mitigate the concern of one of them not working properly. So it's probably also fine to leave them as-is. Either way, we should comment that the duration is specifically chosen such that we are in the middle of a hero animation (if there is one).

late PerAccountStore store;
late FakeApiConnection connection;

Future<void> setupMessageListPage(WidgetTester tester, {
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 keep this helper close to where it is used in the 'LightBoxHero' group.

Comment on lines 212 to 213
List<Message>? messages,
List<ZulipStream>? streams,
Copy link
Member

Choose a reason for hiding this comment

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

Because none of the two callers actually rely on having different messages/channels, it looks like we can remove these parameters. An advantage of keeping the helper close to where it's used is that we can more easily spot what properties the tests rely on.

For things that are constant to the tests (like the topic name, finders), we can have a shared local variable within the group.

Comment on lines 613 to 588
connection.prepare(json:
eg.newestGetMessagesResult(foundOldest: true, messages: [message]).toJson());
Copy link
Member

Choose a reason for hiding this comment

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

nit: move this to the line just before tester.tap, because it prepares an API response for that

await tester.pump(const Duration(milliseconds: 150));

final imageInTransition = tester.getRect(imageFinder);
check(imageInTransition.top).not((it) => it.equals(initialImageRect.top));
Copy link
Member

Choose a reason for hiding this comment

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

I missed this in the previous review — we can further change this to

check(imageInTransition).top.not((it) => it.equals(initialImageRect.top));

@lakshya1goel
Copy link
Contributor Author

Pushed the revision, PTAL @PIG208. Thanks!

Copy link
Member

@PIG208 PIG208 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 update! I found a place where we can potentially shake off the dependency on PageRoot. Let me know what you think!

Comment on lines 580 to 581
final message = eg.streamMessage(stream: channel,
contentMarkdown: ContentExample.imageSingle.html, topic: 'test topic');
Copy link
Member

Choose a reason for hiding this comment

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

nit: indentation

Suggested change
final message = eg.streamMessage(stream: channel,
contentMarkdown: ContentExample.imageSingle.html, topic: 'test topic');
final message = eg.streamMessage(stream: channel,
contentMarkdown: ContentExample.imageSingle.html, topic: 'test topic');

Comment on lines 590 to 585

await store.addUser(eg.selfUser);
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 store.addUser(eg.selfUser);
await store.addUser(eg.selfUser);

because store.addUser is a part of the setup code stanza

@@ -663,12 +665,14 @@ class MessageImage extends StatelessWidget {
src: resolvedSrcUrl,
thumbnailUrl: resolvedThumbnailUrl,
originalWidth: node.originalWidth,
originalHeight: node.originalHeight));
originalHeight: node.originalHeight,
pageContext: pageContext));
Copy link
Member

Choose a reason for hiding this comment

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

While testing this, I tried passing context to pageContext (and below), and it appears to work. Let's try this so that we don't need to rely on having a PageRoot.

@lakshya1goel
Copy link
Contributor Author

Pushed the revision @PIG208, please have a look. Thanks!

@PIG208
Copy link
Member

PIG208 commented Mar 3, 2025

Thanks! I think because we are not using the context of PageRoot, there is probably a better name than pageConext. (Maybe messageImageContext?) Marking this for Greg's review.

@PIG208 PIG208 added integration review Added by maintainers when PR may be ready for integration and removed maintainer review PR ready for review by Zulip maintainers labels Mar 3, 2025
@PIG208 PIG208 assigned gnprice and unassigned PIG208 Mar 3, 2025
@PIG208 PIG208 requested review from gnprice and removed request for PIG208 March 3, 2025 18:34
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 @lakshya1goel for taking this on, and @PIG208 for the previous reviews! Comments below.

Comment on lines 614 to 617
required Uri? thumbnailUrl,
required double? originalWidth,
required double? originalHeight,
required BuildContext pageContext,
Copy link
Member

Choose a reason for hiding this comment

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

When a function has a bunch of parameters like this (or a class has a bunch of fields, etc.), it's important to keep them organized logically — that makes a real difference both for people trying to understand and make changes to the implementation of the function (or class etc.) itself, and for people trying to use it.

So that means new parameters should go in the position that makes the structure of the list make sense, not necessarily at the end of the list.

What are existing parameters on this function that this new one does a similar job to?


final int messageId;
final Uri src;
final BuildContext pageContext;
Copy link
Member

Choose a reason for hiding this comment

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

In addition to Zixuan's point above at #1348 (comment) about the name of this field, let's also give it a bit of dartdoc explaining what it's expected to be. I think that will help with thinking through the behavior.

Comment on lines 21 to 22
// fly to an image preview with a different URL, following a message edit
// while the lightbox was open.
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 points out one of the scenarios that can make it complicated to get these tags right — a message can be edited while the user has one of its images open in the lightbox. How does this version behave if that happens?

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 the current implementation handles this scenario well. When a message is edited while its image is open in the lightbox, the behavior depends on whether the image URL changes:

  • If the edit changes the image URL:
    The Hero animation won't find a matching tag (because src is part of the tag and has changed)
    It will gracefully fall back to a fade transition instead of attempting to animate to the wrong image
  • If the edit doesn't change the image URL:
    All three tag components (messageId, src, and messageImageContext) still match
    The Hero animation will work normally

Copy link
Member

Choose a reason for hiding this comment

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

Bump — it seems like this PR resolves this TODO comment. That means the TODO comment should be removed.

It looks like this also fixes the issue the TODO points at, namely #44. So the commit and PR should be marked as fixing that issue.

Because the PR is otherwise ready, I'll go ahead and merge, after making those edits.

A good follow-up for you to do, while this area is fresh in your mind, would be to write a test confirming that #44 is fixed. That test would then serve as a regression test to ensure we don't accidentally reintroduce the issue in the future. In writing such a test, keep in mind the points we've discussed on the tests in this PR, including:

  • make the test as clear and easy to follow as possible;
  • in particular, keep the test focused on the details that are essential for the test;
  • make sure the test thoroughly checks that the behavior is the way it's expected to be, and rules out all kinds of alternative scenarios for buggy ways the code could behave instead.

@@ -558,4 +566,76 @@ void main() {
check(platform.position).equals(position);
});
});

group('LightboxHero', () {
Copy link
Member

Choose a reason for hiding this comment

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

nit: keep tests in an order matching the order of the code they're testing

This is really another example of the same point as the comment above about function parameters — when adding something new, take a moment to think about the most logical place for it to appear, rather than just putting it at the end.

Comment on lines 614 to 616
final imageInTransition = tester.getRect(imageFinder);
check(imageInTransition).top.equals(initialImageRect.top);
check(imageInTransition).left.equals(initialImageRect.left);
Copy link
Member

Choose a reason for hiding this comment

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

Given imageFinder = find.byType(RealmContentNetworkImage).first, this doesn't make an entirely convincing check that there isn't a hero animation going on. This effectively says that the original image is still the first RealmContentNetworkImage in the tree — but if there were a hero animation going on, there's no reason that necessarily has to be the first image in the tree. Maybe it comes later in the tree than the original image.

Comment on lines 634 to 636
final imageInTransition = tester.getRect(imageFinder);
check(imageInTransition).top.not((it) => it.equals(initialImageRect.top));
check(imageInTransition).left.not((it) => it.equals(initialImageRect.left));
Copy link
Member

Choose a reason for hiding this comment

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

Similarly, this says that the first RealmContentNetworkImage in the tree isn't at the place the original image was. But

  • there's the new route, the lightbox, which has a version of the image — maybe that's the first one in the tree now
  • there's the old route, the message list, which may have a version of the image — and although right now that won't have moved, maybe in the future we'll use a different navigation transition here so that the old route moves away while the new one is moving in. Again maybe that's the first one in the tree now.

So this should get more specific about our expectations in order to be convincing that it wouldn't end up passing even in some future where we've broken the hero animation entirely.

@lakshya1goel
Copy link
Contributor Author

Pushed the revision, PTAL @gnprice, thanks!

Comment on lines 328 to 339
imagePositionsOverTime.putIfAbsent(element, () => []);
imagePositionsOverTime[element]!.add(position);
}
}

for (final element in imagePositionsOverTime.keys) {
final positions = imagePositionsOverTime[element]!;
check(positions).isNotEmpty();

final initialPos = positions[0];
for (final position in positions) {
check(position).equals(initialPos);
Copy link
Member

Choose a reason for hiding this comment

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

This still isn't very specific, because it doesn't check that the set of elements at one step has any relationship to the set of elements at another step.

For example, one plausible buggy behavior would be that at each step, there's a new image element and the old image element is gone. From the user's perspective this could produce an animation, because the new element could be at a different position along an animation path. But that would pass this test, because each list would have just one entry in it.

Copy link
Member

Choose a reason for hiding this comment

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

Instead, try this:

  • Before navigating to the second page, find the one image element and remember its location.
  • While on the second page, find the one image element that's now visible and remember its location.
  • Once the animation starts, at each step, check that the set of image elements is exactly those two image elements.
  • Also at each step, check that each image element is in the same place as it was at the start.

Does that pass? If so, great — that's very specific and makes clear what the behavior is.

If not, then loosen the conditions a little in order to make it pass. But experiment to find just how and why the specific version fails, so that you can loosen it in only the minimal way that makes it pass, keeping it as specific as possible.

@lakshya1goel lakshya1goel force-pushed the issue930 branch 2 times, most recently from b9788fc to 6c64509 Compare April 4, 2025 05:01
@lakshya1goel
Copy link
Contributor Author

Hi, @gnprice just pushed the revision following the comments above. The test is passing without loosening the constraints.
PTAL, Thanks!

@lakshya1goel lakshya1goel requested a review from gnprice April 4, 2025 05:05
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! The logic for the tests looks good now — I think these tests will do their job effectively.

There's a variety of things that can be done now to make the tests simpler and easier to understand. Here's a review focused on those.

We're closing in now on getting this PR to something we can merge — I didn't want to get into this sort of style and clarity feedback until we knew there weren't bigger changes we were going to make.

Comment on lines 332 to 335
check(currentImages)
..length.equals(2)
..contains(firstElement)
..contains(secondElement);
Copy link
Member

Choose a reason for hiding this comment

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

Fun shorthand for this:

Suggested change
check(currentImages)
..length.equals(2)
..contains(firstElement)
..contains(secondElement);
check(currentImages).unorderedEquals([firstElement, secondElement]);

Comment on lines 343 to 347
for (final position in firstImagePositions) {
check(position).equals(firstImagePosition);
}
for (final position in secondImagePositions) {
check(position).equals(secondImagePosition);
Copy link
Member

Choose a reason for hiding this comment

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

These checks aren't really benefitting from getting saved until after the loop — they'll still print exactly the information of the first one of these that fails. So they can be simplified by getting moved inside the loop, and skipping the firstImagePositions etc. lists.

Comment on lines 330 to 331
final currentImages = find.byWidgetPredicate((widget) =>
widget is RealmContentNetworkImage && widget.src == imageSrcUrl).evaluate();
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 the same finder as secondImageFinder, right? It's really "find images with this URL". So it'd be good to combine them, under a name that reflects that shared meaning.

They can also replace firstImageFinder, if we just identify the URL in advance.

And we can do that, because this test controls what the URL is going to be — it set up the message list and supplied the message HTML.

Probably cleanest is to just duplicate the URL from that ContentExample value. It's unlikely to change; if it does change, the tests will fail because they won't find elements they expect. So like:

    final message = eg.streamMessage(stream: channel,
      contentMarkdown: ContentExample.imageSingle.html, topic: 'test topic');
    
    // From ContentExample.imageSingle.
    final imageSrcUrlStr = '/user_uploads/2/ce/nvoNL2LaZOciwGZ-FYagddtK/image.jpg';
    final imageSrcUrl = …;
    final imageFinder = …;

Comment on lines 317 to 318
final backButton = find.byType(BackButton);
await tester.tap(backButton);
Copy link
Member

Choose a reason for hiding this comment

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

nit: simpler if inlined

Comment on lines 303 to 291
connection.prepare(json:
eg.newestGetMessagesResult(foundOldest: true, messages: [message]).toJson());

await tester.tap(find.descendant(
of: find.byType(StreamMessageRecipientHeader),
matching: find.text('test topic')));
await tester.pumpAndSettle();
Copy link
Member

Choose a reason for hiding this comment

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

nit: these are logically one operation, so join as one stanza:

Suggested change
connection.prepare(json:
eg.newestGetMessagesResult(foundOldest: true, messages: [message]).toJson());
await tester.tap(find.descendant(
of: find.byType(StreamMessageRecipientHeader),
matching: find.text('test topic')));
await tester.pumpAndSettle();
connection.prepare(json:
eg.newestGetMessagesResult(foundOldest: true, messages: [message]).toJson());
await tester.tap(find.descendant(
of: find.byType(StreamMessageRecipientHeader),
matching: find.text('test topic')));
await tester.pumpAndSettle();

const heroAnimationDuration = Duration(milliseconds: 300);
const steps = 150;
final stepDuration = heroAnimationDuration ~/ steps;
List<Rect> animatedPositions = [];
Copy link
Member

Choose a reason for hiding this comment

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

nit: final

Copy link
Member

Choose a reason for hiding this comment

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

const (which is stronger than final) was fine for the locals where it applied — this comment was meant for animatedPositions 🙂

(In GitHub's review interface, if you make a comment targeting just a single line — like this one was — it shows the 4 lines up through that one as the context. That does makes it look a bit ambiguous, though in cases where the difference matters it is possible to find if you look closely: some comments say "Comment on lines MMM to NNN" above them.)

The principle here is that when a value is marked as immutable, that makes the code easier for the reader to reason about. So whenever we don't actually need to mutate it (like we do for loop variables like previousPosition), it's best to declare it final or const.

Comment on lines 212 to 213
final message = eg.streamMessage(stream: channel,
contentMarkdown: ContentExample.imageSingle.html, topic: 'test topic');
Copy link
Member

Choose a reason for hiding this comment

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

nit: order elements logically: so stream, topic, then content, because the stream and topic give the more general context where the content appears

@lakshya1goel
Copy link
Contributor Author

Hi, @gnprice I have pushed the revision. PTAL, Thanks!

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! This is getting closer; here's comments on this version.

(This review is a bit delayed because I was out sick part of last week.)

late FakeApiConnection connection;

final channel = eg.stream();
final imageSrcUrlStr = 'https://chat.example/user_uploads/thumbnail/2/ce/nvoNL2LaZOciwGZ-FYagddtK/image.jpg/840x560.webp';
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 should have a comment saying where it comes from, as it otherwise looks quite random — cf #1348 (comment)

const heroAnimationDuration = Duration(milliseconds: 300);
const steps = 150;
final stepDuration = heroAnimationDuration ~/ steps;
List<Rect> animatedPositions = [];
Copy link
Member

Choose a reason for hiding this comment

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

const (which is stronger than final) was fine for the locals where it applied — this comment was meant for animatedPositions 🙂

(In GitHub's review interface, if you make a comment targeting just a single line — like this one was — it shows the 4 lines up through that one as the context. That does makes it look a bit ambiguous, though in cases where the difference matters it is possible to find if you look closely: some comments say "Comment on lines MMM to NNN" above them.)

The principle here is that when a value is marked as immutable, that makes the code easier for the reader to reason about. So whenever we don't actually need to mutate it (like we do for loop variables like previousPosition), it's best to declare it final or const.

Comment on lines 240 to 247
final messageContentFinder = find.byWidgetPredicate((widget) =>
widget is MessageContent && widget.message.id == message.id
);
final messageListImageFinder = find.descendant(
of: messageContentFinder,
matching: find.byType(RealmContentNetworkImage)
);
final initialImagePosition = tester.getRect(messageListImageFinder);
Copy link
Member

Choose a reason for hiding this comment

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

This can be simplified to work the same way as the corresponding part of the other test case. (And then imageFinder can be shared between the test cases.)

Comment on lines 319 to 320
check(tester.getRect(
find.byElementPredicate((e) => e == firstElement))).equals(firstImagePosition);
Copy link
Member

Choose a reason for hiding this comment

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

nit: line too long; instead:

Suggested change
check(tester.getRect(
find.byElementPredicate((e) => e == firstElement))).equals(firstImagePosition);
check(tester.getRect(find.byElementPredicate((e) => e == firstElement)))
.equals(firstImagePosition);

Copy link
Member

Choose a reason for hiding this comment

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

Better yet, make a little helper function within this test case, and then:

Suggested change
check(tester.getRect(
find.byElementPredicate((e) => e == firstElement))).equals(firstImagePosition);
check(getElementRect(firstElement)).equals(firstImagePosition);

Then the same getElementRect can be used for defining firstImagePosition in terms of firstElement in the first place. That way this check works the same way as firstImagePosition got computed originally — so we're doing the same thing the same way both times, instead of two somewhat different ways, and that means one fewer thing for the reader to understand and think through.

Comment on lines 316 to 317
final currentImages = imageFinder.evaluate();
check(currentImages).unorderedEquals([firstElement, secondElement]);
Copy link
Member

Choose a reason for hiding this comment

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

nit: simpler if inlined

widget is RealmContentNetworkImage && widget.src == imageSrcUrl
);
final firstImagePosition = tester.getRect(imageFinder);
final firstElement = imageFinder.evaluate().single;
Copy link
Member

Choose a reason for hiding this comment

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

Bit cleaner (as evaluate is meant really as internal API for implementing these finders):

Suggested change
final firstElement = imageFinder.evaluate().single;
final firstElement = tester.element(imageFinder);

Comment on lines 273 to 275
final stepDistance = sqrt(
pow(position.top - previousPosition.top, 2) +
pow(position.left - previousPosition.left, 2));
Copy link
Member

Choose a reason for hiding this comment

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

The logic here can become a bit easier to see by making a small helper function inside the test case:

Suggested change
final stepDistance = sqrt(
pow(position.top - previousPosition.top, 2) +
pow(position.left - previousPosition.left, 2));
final stepDistance = dist(previousPosition, position);

Particularly for comparison with totalDistance above.

Comment on lines 225 to 234

connection.prepare(json:
eg.newestGetMessagesResult(foundOldest: true, messages: [message]).toJson());

await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id,
child: MessageListPage(initNarrow: const CombinedFeedNarrow())));

await tester.pumpAndSettle();
Copy link
Member

Choose a reason for hiding this comment

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

nit: these are basically one operation; join them as one stanza:

Suggested change
connection.prepare(json:
eg.newestGetMessagesResult(foundOldest: true, messages: [message]).toJson());
await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id,
child: MessageListPage(initNarrow: const CombinedFeedNarrow())));
await tester.pumpAndSettle();
connection.prepare(json:
eg.newestGetMessagesResult(foundOldest: true, messages: [message]).toJson());
await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id,
child: MessageListPage(initNarrow: const CombinedFeedNarrow())));
await tester.pumpAndSettle();

@lakshya1goel
Copy link
Contributor Author

Hi, I have pushed the revision. PTAL @gnprice. Thanks!

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! Down now to a couple of small comments, both of them echoes of comments from the last round.

final stepDuration = heroAnimationDuration ~/ steps;
for (int i = 0; i < steps; i++) {
await tester.pump(stepDuration);
check(imageFinder.evaluate()).unorderedEquals([firstElement, secondElement]);
Copy link
Member

Choose a reason for hiding this comment

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

nit: similar comment as: #1348 (comment)

Comment on lines 211 to 219
final channel = eg.stream();
// From ContentExample.imageSingle.
final imageSrcUrlStr = 'https://chat.example/user_uploads/thumbnail/2/ce/nvoNL2LaZOciwGZ-FYagddtK/image.jpg/840x560.webp';
final imageSrcUrl = Uri.parse(imageSrcUrlStr);
final message = eg.streamMessage(stream: channel,
topic: 'test topic', contentMarkdown: ContentExample.imageSingle.html);

final imageFinder = find.byWidgetPredicate(
(widget) => widget is RealmContentNetworkImage && widget.src == imageSrcUrl);
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 is clearer if rearranged (like at #1348 (comment)):

Suggested change
final channel = eg.stream();
// From ContentExample.imageSingle.
final imageSrcUrlStr = 'https://chat.example/user_uploads/thumbnail/2/ce/nvoNL2LaZOciwGZ-FYagddtK/image.jpg/840x560.webp';
final imageSrcUrl = Uri.parse(imageSrcUrlStr);
final message = eg.streamMessage(stream: channel,
topic: 'test topic', contentMarkdown: ContentExample.imageSingle.html);
final imageFinder = find.byWidgetPredicate(
(widget) => widget is RealmContentNetworkImage && widget.src == imageSrcUrl);
final channel = eg.stream();
final message = eg.streamMessage(stream: channel,
topic: 'test topic', contentMarkdown: ContentExample.imageSingle.html);
// From ContentExample.imageSingle.
final imageSrcUrlStr = 'https://chat.example/user_uploads/thumbnail/2/ce/nvoNL2LaZOciwGZ-FYagddtK/image.jpg/840x560.webp';
final imageSrcUrl = Uri.parse(imageSrcUrlStr);
final imageFinder = find.byWidgetPredicate(
(widget) => widget is RealmContentNetworkImage && widget.src == imageSrcUrl);

There's basically two narratives (or mini-narratives) here: setting up message, and setting up imageFinder. This way the reader gets through one, then goes through the other, rather than interleaving them.

This way also makes the comment on imageSrcUrlStr make more sense — the reason ContentExample.imageSingle is relevant is that it's what's used in message. When this comes shortly after message rather than before it, the reader has that context.

Comment on lines 304 to 307
check(tester.element(imageFinder.first)).anyOf([
(e) => e.equals(firstElement), (e) => e.equals(secondElement)]);
check(tester.element(imageFinder.last)).anyOf([
(e) => e.equals(firstElement), (e) => e.equals(secondElement)]);
Copy link
Member

Choose a reason for hiding this comment

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

This no longer checks that the situation is the way we expect: it allows there to be three or more elements matching imageFinder.

It's also a lot more code to read than an unorderedEquals check. (Cf. my previous comment #1348 (comment) .)

Using unorderedEquals is the right way to do this. You'll want something different from tester.element, though.

Take a look around the API surrounding tester.element to see what variations of it you can find that would be useful here. I recommend jumping in your IDE to the definition of tester.element — that will let you browse around the neighboring code.

pubspec.lock Outdated
Comment on lines 652 to 653
name: leak_tracker
sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0"
sha256: "8dcda04c3fc16c14f48a7bb586d4be1f0d1572731b6d81d51772ef47c02081e0"
Copy link
Member

Choose a reason for hiding this comment

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

This diff doesn't seem related to what this commit is doing. To keep the commits clear and coherent, this change should be kept to a separate commit.

It should also go with a corresponding change in pubspec.yaml. See our README about upgrading Flutter.

@lakshya1goel
Copy link
Contributor Author

Hi @gnprice, I have pushed the correction please have a look. Thanks!

@lakshya1goel lakshya1goel force-pushed the issue930 branch 3 times, most recently from 784d83a to 9a58e05 Compare May 1, 2025 03:55
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 @lakshya1goel for all your work on this!

The tests all look good now, modulo one nit.

See also one more-substantive comment below. But I think it doesn't call for any code changes, only comments and metadata.

So I'll make those edits and merge. I'll also add a commit that revises the docs from this PR.

From reading the TODO comment that I remarked on in an earlier review, I see that this in fact fixes not only #930 but also another issue #44. A good followup for you would be to do next would be to write a regression test to ensure that #44 stays fixed; see details below.

Comment on lines 304 to 305
check(tester.elementList(imageFinder)).unorderedEquals([
firstElement, secondElement]);
Copy link
Member

Choose a reason for hiding this comment

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

nit: follow the pattern of other check calls in the codebase:

Suggested change
check(tester.elementList(imageFinder)).unorderedEquals([
firstElement, secondElement]);
check(tester.elementList(imageFinder))
.unorderedEquals([firstElement, secondElement]);

Comment on lines 21 to 22
// fly to an image preview with a different URL, following a message edit
// while the lightbox was open.
Copy link
Member

Choose a reason for hiding this comment

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

Bump — it seems like this PR resolves this TODO comment. That means the TODO comment should be removed.

It looks like this also fixes the issue the TODO points at, namely #44. So the commit and PR should be marked as fixing that issue.

Because the PR is otherwise ready, I'll go ahead and merge, after making those edits.

A good follow-up for you to do, while this area is fresh in your mind, would be to write a test confirming that #44 is fixed. That test would then serve as a regression test to ensure we don't accidentally reintroduce the issue in the future. In writing such a test, keep in mind the points we've discussed on the tests in this PR, including:

  • make the test as clear and easy to follow as possible;
  • in particular, keep the test focused on the details that are essential for the test;
  • make sure the test thoroughly checks that the behavior is the way it's expected to be, and rules out all kinds of alternative scenarios for buggy ways the code could behave instead.

@gnprice gnprice merged commit f3da704 into zulip:main May 1, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
integration review Added by maintainers when PR may be ready for integration
Projects
None yet
3 participants