Skip to content

Conversation

@chrisbobbe
Copy link
Collaborator

Fixes #1936.

@chrisbobbe chrisbobbe added the maintainer review PR ready for review by Zulip maintainers label Nov 13, 2025
Copy link
Member

@rajveermalviya rajveermalviya left a comment

Choose a reason for hiding this comment

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

Thanks @chrisbobbe! LGTM, just one small comment.

Comment on lines 29 to 31
--data-urlencode 'narrow=[{"operator":"sender", "operand":2187},
--data-urlencode 'narrow=[{"operator":"sender", "operand":13313},
{"operator":"stream", "operand":"test here"},
{"operator":"topic", "operand":"content"}]' \
{"operator":"topic", "operand":"Thumbnails"}]' \
Copy link
Member

Choose a reason for hiding this comment

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

Left from debug?

@rajveermalviya rajveermalviya 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 Nov 17, 2025
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 taking care of this! Comments below.

Comment on lines 557 to 559
/// Use [ImageThumbnailLocatorExtension.resolve] to obtain a suitable URL
/// for the current UI need.
/// It may not work without adding authentication credentials to the request.
Copy link
Member

Choose a reason for hiding this comment

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

This seems like information for the ImageThumbnailLocator doc.

Copy link
Member

Choose a reason for hiding this comment

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

bump — I meant this whole 3-line paragraph 🙂

}

@override
int get hashCode => Object.hash(urlPath, hasAnimatedVersion);
Copy link
Member

Choose a reason for hiding this comment

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

In hashCode, seed the hash with the class name so that the hash doesn't collide with other objects that happen to also consist of a string and a bool:

Suggested change
int get hashCode => Object.hash(urlPath, hasAnimatedVersion);
int get hashCode => Object.hash('ImageThumbnailLocator', urlPath, hasAnimatedVersion);

Comment on lines 738 to 741
thumbnail: ImageThumbnailLocator(
urlPath: '/user_uploads/thumbnail/2/ce/nvoNL2LaZOciwGZ-FYagddtK/image.jpg/840x560.webp',
hasAnimatedVersion: false,
),
Copy link
Member

Choose a reason for hiding this comment

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

nit: can keep these a bit more compact:

Suggested change
thumbnail: ImageThumbnailLocator(
urlPath: '/user_uploads/thumbnail/2/ce/nvoNL2LaZOciwGZ-FYagddtK/image.jpg/840x560.webp',
hasAnimatedVersion: false,
),
thumbnail: ImageThumbnailLocator(hasAnimatedVersion: false,
urlPath: '/user_uploads/thumbnail/2/ce/nvoNL2LaZOciwGZ-FYagddtK/image.jpg/840x560.webp'),

Comment on lines 758 to 759
urlPath: '/user_uploads/thumbnail/2/9f/tZ9c5ZmsI_cSDZ6ZdJmW8pt4/2c8d985d.gif/840x560-anim.webp',
hasAnimatedVersion: true,
Copy link
Member

Choose a reason for hiding this comment

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

Hmm. From this URL path, it sounds like the given path is the animated version. That makes the name "has animated version" seem a bit misleading.

The parser shows the attribute name in the HTML is data-animated. The most direct translation of that would be to call this field just animated. How about we use that name?

Comment on lines -562 to +563
final String? thumbnailUrl;
final ImageThumbnailLocator? thumbnail;
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 it'd be helpful to split this commit:

  • An NFC prep commit makes the change on this line, and the other changes that makes necessary, with the new class having only urlPath.
  • Then a separate commit adds hasAnimatedVersion (or animated per another comment).

There are a lot of places that change in boring ways just from moving this URL path out to the new class, so it'd make the substantive changes easier to read if they were in a separate commit from that.

Copy link
Member

Choose a reason for hiding this comment

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

Very helpful, thanks 🙂

Comment on lines 172 to 180
final store = PerAccountStoreWidget.of(context);
ThumbnailFormat? bestCandidate;

final animateIfSupported = animationMode.resolve(context);
if (hasAnimatedVersion && animateIfSupported) {
bestCandidate ??= _bestFormatOf(store.sortedAnimatedThumbnailFormats,
width: widthPhysicalPx, height: heightPhysicalPx);
}

bestCandidate ??= _bestFormatOf(store.sortedStillThumbnailFormats,
width: widthPhysicalPx, height: heightPhysicalPx);
Copy link
Member

Choose a reason for hiding this comment

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

nit: join up the bestCandidate computation as one stanza (and separate it from getting store)

Comment on lines +184 to +187
if (bestCandidate == null) {
// Odd if we'd need to fall back to the format encoded in [locator]'s path.
// Seems theoretically possible though:
// maybe this format isn't used now, for new uploads,
// but it was used in the past, including for this image.
return store.realmUrl.replace(path: urlPath);
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 normal if there are no thumbnail formats at all, right?

In fact I think if this happens, it must be the case that there are no still thumbnail formats.

Copy link
Collaborator Author

@chrisbobbe chrisbobbe Dec 6, 2025

Choose a reason for hiding this comment

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

By "there are no thumbnail formats at all", do you mean server_thumbnail_formats in the /register response was empty? I assume the common reason for that is that no uploaded images have ever been thumbnailed, and in that scenario this code won't be reached.

This condition is about what to do when server_thumbnail_formats was empty but somehow we have a thumbnail URL in our hands. A thumbnail URL encodes a thumbnail format at the end, like "/840x560.webp". I'm assuming this can happen if an image was thumbnailed but then later the server admin said "hmm actually we don't want to do thumbnailing for new uploads anymore". We can be optimistic that the thumbnail is still reachable and go ahead and request it.

Copy link
Member

Choose a reason for hiding this comment

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

I see. I think the "Odd if …" confused me — it sounded like it was saying this case is odd if it means we need to fall back.

Does this version cover it?

Suggested change
if (bestCandidate == null) {
// Odd if we'd need to fall back to the format encoded in [locator]'s path.
// Seems theoretically possible though:
// maybe this format isn't used now, for new uploads,
// but it was used in the past, including for this image.
return store.realmUrl.replace(path: urlPath);
if (bestCandidate == null) {
// There are no known thumbnail formats applicable; and yet we have this
// thumbnail locator, indicating this image has a thumbnail.
// Unlikely but seems theoretically possible:
// maybe thumbnailing isn't used now, for new uploads,
// but it was used in the past, including for this image.
// Anyway, fall back to the format encoded in this locator's path.
return store.realmUrl.replace(path: urlPath);

Comment on lines +189 to +190
return store.realmUrl.replace(path: urlPath);
}

final lastSlashIndex = urlPath.lastIndexOf('/');
Copy link
Member

Choose a reason for hiding this comment

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

These lines both assume that the URL string in urlPath is indeed just a path, and doesn't also contain a query and/or fragment.

I think it'd be reasonable for the API to guarantee that, but it doesn't appear to now (at https://zulip.com/api/message-formatting#images ). Without such a guarantee, a query component (though maybe not a fragment) would be a reasonable choice for this bit of API in isolation — e.g. Gravatar does that, as seen in our GravatarUrl.

(OTOH we do know it starts with a path component, because it starts with /u.)

So it'd be good to discuss that in #api documentation or #api design. Also to have the ImageThumbnailLocator constructor assert it, like it already does for the start of the path. A suitable check would be that there is no ? or # character.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Copy link
Member

Choose a reason for hiding this comment

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

Following that discussion, let's have this accommodate a query. (And it's fine if it also accepts a fragment; the spec might start promising that won't appear, since it wouldn't make much sense, but our code won't need to try to enforce that.)

One way to do that is to first find the query, if any, using .indexOf('?'). (The character ? can't appear in a URL path string: https://url.spec.whatwg.org/#path-absolute-url-string .)

'https://chat.example/user_uploads/thumbnail/1/2/a/pic.jpg/1000x2000.png');
});

testWidgets('animated version does not exist', (tester) async {
Copy link
Member

Choose a reason for hiding this comment

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

nit: probably clearest to put this test before its sibling — this seems like the simpler case to think about, and covers most of the interesting logic (and is surely the most common case)

Then the other test can focus just on what changes when hasAnimatedVersion is true.

Comment on lines 148 to 151
doCheck(tester, 250, 450, false, hasAnimatedVersion: false,
'https://chat.example/user_uploads/thumbnail/1/2/a/pic.jpg/500x900.jpg');
doCheck(tester, 300, 250, true, hasAnimatedVersion: false,
'https://chat.example/user_uploads/thumbnail/1/2/a/pic.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.

Hmm, why do these get different results? Both formats should be big enough for both these desired sizes, right?

… Ah, I see: it's because they're in physical pixels.

Would be good to have a comment highlighting that. 🙂 That's also one of the key points this test is making, so it's good to make it explicit.

@chrisbobbe chrisbobbe force-pushed the pr-thumbnail-formats branch from 7d51241 to c7bb1d8 Compare December 6, 2025 02:16
@chrisbobbe
Copy link
Collaborator Author

Thanks! Revision pushed.

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! Comments below.

Comment on lines +746 to +747
static final imagePreviewSingleAnimated = ContentExample(
'single image preview, with animated version',
Copy link
Member

Choose a reason for hiding this comment

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

nit: something like

Suggested change
static final imagePreviewSingleAnimated = ContentExample(
'single image preview, with animated version',
static final imagePreviewSingleAnimated = ContentExample(
'single image preview, animated',

since the animated version is the primary one

Comment on lines 557 to 559
/// Use [ImageThumbnailLocatorExtension.resolve] to obtain a suitable URL
/// for the current UI need.
/// It may not work without adding authentication credentials to the request.
Copy link
Member

Choose a reason for hiding this comment

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

bump — I meant this whole 3-line paragraph 🙂

Comment on lines 692 to +696
name: matcher
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
url: "https://pub.dev"
source: hosted
version: "0.12.17"
version: "0.12.18"
Copy link
Member

Choose a reason for hiding this comment

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

Looks like unintended extraneous changes here.

Comment on lines -562 to +563
final String? thumbnailUrl;
final ImageThumbnailLocator? thumbnail;
Copy link
Member

Choose a reason for hiding this comment

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

Very helpful, thanks 🙂

'https://chat.example/user_uploads/thumbnail/1/2/a/pic.jpg/500x850.jpg');
doCheck(tester, 250, 425, false, animated: false,
'https://chat.example/user_uploads/thumbnail/1/2/a/pic.jpg/500x850.jpg');
// Different output from previous because it's is in physical pixels.
Copy link
Member

Choose a reason for hiding this comment

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

Hmm, this doesn't really explain how that connects to the test data. It also sounds like it's saying the difference is that either this item or the previous one is in physical pixels (and the other isn't).

I'll push an added commit regrouping these and adding a few more comments.

Comment on lines +184 to +187
if (bestCandidate == null) {
// Odd if we'd need to fall back to the format encoded in [locator]'s path.
// Seems theoretically possible though:
// maybe this format isn't used now, for new uploads,
// but it was used in the past, including for this image.
return store.realmUrl.replace(path: urlPath);
Copy link
Member

Choose a reason for hiding this comment

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

I see. I think the "Odd if …" confused me — it sounded like it was saying this case is odd if it means we need to fall back.

Does this version cover it?

Suggested change
if (bestCandidate == null) {
// Odd if we'd need to fall back to the format encoded in [locator]'s path.
// Seems theoretically possible though:
// maybe this format isn't used now, for new uploads,
// but it was used in the past, including for this image.
return store.realmUrl.replace(path: urlPath);
if (bestCandidate == null) {
// There are no known thumbnail formats applicable; and yet we have this
// thumbnail locator, indicating this image has a thumbnail.
// Unlikely but seems theoretically possible:
// maybe thumbnailing isn't used now, for new uploads,
// but it was used in the past, including for this image.
// Anyway, fall back to the format encoded in this locator's path.
return store.realmUrl.replace(path: urlPath);

Comment on lines +189 to +190
return store.realmUrl.replace(path: urlPath);
}

final lastSlashIndex = urlPath.lastIndexOf('/');
Copy link
Member

Choose a reason for hiding this comment

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

Following that discussion, let's have this accommodate a query. (And it's fine if it also accepts a fragment; the spec might start promising that won't appear, since it wouldn't make much sense, but our code won't need to try to enforce that.)

One way to do that is to first find the query, if any, using .indexOf('?'). (The character ? can't appear in a URL path string: https://url.spec.whatwg.org/#path-absolute-url-string .)

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

Development

Successfully merging this pull request may close these issues.

content: Disable image-preview animations, subject to device settings

3 participants