Skip to content

Conversation

@chrisbobbe
Copy link
Collaborator

Fixes #1913.

This relies on an API guarantee that's implicit in the web PR zulip/zulip#36226, but I've left a PR review suggesting more explicitness: zulip/zulip#36226 (review) . In particular, this assumes that src in the inline-image HTML will be a thumbnail URL, i.e. one that starts with '/user_uploads/thumbnail/'.

See also some followup issues:

Screenshots coming soon. 🙂

@chrisbobbe chrisbobbe added the maintainer review PR ready for review by Zulip maintainers label Jan 8, 2026
@chrisbobbe
Copy link
Collaborator Author

cc @alya

Showing a large image being shrunk, and center-aligned vertically in the flow of text:

Web (CZO 2026-01-07) Mobile
image image

Showing a small image:

There's a discrepancy here, but it's web's bug: it blows up the image so that it uses more pixels than the image has data for, and it's blurry. I started a discussion: #design > Inline images blown up and blurry @ 💬

Web (CZO 2026-01-07) Mobile
image image

Showing images in a table:

The table is wider than available space on mobile; I've included a second mobile screenshot where I scrolled the table to see the rest of it.

Web (CZO 2026-01-07) Mobile
image image
image

@chrisbobbe chrisbobbe requested a review from alya January 8, 2026 02:04
@chrisbobbe chrisbobbe added the product review Added by maintainers when a PR needs product review label Jan 8, 2026
@chrisbobbe
Copy link
Collaborator Author

Revision pushed. Having seen @timabbott's comment zulip/zulip#36226 (comment) , I've relaxed the "image-loading-placeholder" case in the parser so that it doesn't expect src to be a thumbnail URL.

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! All LGTM, and tests great on a local server (except I couldn't test the loading state, not sure if there's an easy way to do that).

Moving over to Greg's review.

@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 Jan 9, 2026
@rajveermalviya rajveermalviya requested a review from gnprice January 9, 2026 21:35
Comment on lines 2235 to 2239
//|//////////////////////////////////////////////////////////////
// Helpers for both [_ZulipInlineContentParser] and [_ZulipContentParser].


final _imageDimensionsRegExp = RegExp(r'^(\d+)x(\d+)$');
Copy link
Member

Choose a reason for hiding this comment

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

nit: put helpers above rather than below (that's how this parser code is organized in general)

Comment on lines 1188 to 1192
final dimensionsMatch = _imageDimensionsRegExp.firstMatch(originalDimensions);
if (dimensionsMatch == null) return null;
final originalWidth = int.tryParse(dimensionsMatch.group(1)!, radix: 10)?.toDouble();
final originalHeight = int.tryParse(dimensionsMatch.group(2)!, radix: 10)?.toDouble();
if (originalWidth == null || originalHeight == null) return null;
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 some nontrivial logic that feels like it's doing very much the same thing as the other site where this regexp is used. Can that be factored out as a helper function?

(The helper can return an ad-hoc record type with width and height)

Comment on lines 1184 to 1185
final originalSrc = imgElement.attributes['data-original-src'];
if (originalSrc == null) return null;
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 we decided in the chat thread that these images might not be thumbnailed. This isn't yet…

… ah I see, this revision is from before we decided that 🙂. I'll hold off on reviewing further for now, then.

(see #api design > HTML pattern for truly inline images @ 💬)

@chrisbobbe
Copy link
Collaborator Author

Thanks! Revision pushed, and I've commented at #api design > HTML pattern for truly inline images @ 💬 on how the parser is more permissive of forms that current servers don't produce.

Comment on lines 1248 to 1255
return InlineImageNode(
loading: loading,
src: thumbnailSrc != null
? InlineImageNodeSrcThumbnail(thumbnailSrc)
: InlineImageNodeSrcOther(src),
alt: alt,
originalSrc: originalSrc,
originalWidth: originalWidth,

This comment was marked as resolved.

Copy link
Member

Choose a reason for hiding this comment

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

Oh I see, we do use the original size, naturally enough.

Also apparently alt; I'd missed that we use it in two places.

Copy link
Collaborator Author

@chrisbobbe chrisbobbe Jan 10, 2026

Choose a reason for hiding this comment

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

we never look at any of these other fields on InlineImageNode, right?

We use data-original-dimensions, when present, to avoid layout shifts when transitioning out of the loading state. I think data-original-dimensions will still be present in the loading state, from current servers? Because current servers only intend to operate on uploaded images, and we know the dimensions of those. This is supported by the API doc in zulip/zulip#36226:

For image elements presented in Markdown syntax, this placeholder
structure is used:

```html
<img alt="example image"
  class="inline-image image-loading-placeholder"
  data-original-content-type="image/png"
  data-original-dimensions="1050x700"
  data-original-src="/user_uploads/path/to/example.png"
  src="/path/to/spinner.png">
```

Comment on lines 1241 to 1245
final dimensionsMatch = _imageDimensionsRegExp.firstMatch(originalDimensions);
if (dimensionsMatch != null) {
originalWidth = int.tryParse(dimensionsMatch.group(1)!, radix: 10)?.toDouble();
originalHeight = int.tryParse(dimensionsMatch.group(2)!, radix: 10)?.toDouble();
}
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 meant to use the new helper, right?

Comment on lines 1222 to 1230
final src = imgElement.attributes['src'];
if (src == null) return null;
final animated = imgElement.attributes['data-animated'] == 'true';
ImageThumbnailLocator? thumbnailSrc;
if (src.startsWith(ImageThumbnailLocator.srcPrefix)) {
final srcUrl = Uri.tryParse(src);
if (srcUrl == null) return null;
thumbnailSrc = ImageThumbnailLocator(defaultFormatSrc: srcUrl, animated: animated);
}
Copy link
Member

Choose a reason for hiding this comment

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

And this is some other logic that seems very similar to some code we already have for the block images. It'd be good to either deduplicate, or if that's annoying for some reason then to adjust the two to be written as similarly as possible — that way one can look at the two of them and work out if there are any subtle discrepancies in behavior that we might not intend.

Comment on lines 1043 to 1052
class InlineImageNode extends InlineContentNode {
const InlineImageNode({
super.debugHtmlNode,
required this.loading,
required this.src,
required this.alt,
required this.originalSrc,
required this.originalWidth,
required this.originalHeight,
});
Copy link
Member

Choose a reason for hiding this comment

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

Can we unify this with ImagePreviewNode, by giving them a common base class that supplies most of these fields? It seems like they have much the same data on them.

That might also help with unifying more of the parsing logic for them.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yes, but my effort in this direction has turned into kind of a project. I've sent #2077 to prepare for this; PTAL.

Comment on lines 1382 to 1384
// Don't let tall, thin images take up too much vertical space,
// which could be annoying to scroll through…
BoxConstraints(maxWidth: maxHeight * aspectRatio, maxHeight: maxHeight)
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
// Don't let tall, thin images take up too much vertical space,
// which could be annoying to scroll through…
BoxConstraints(maxWidth: maxHeight * aspectRatio, maxHeight: maxHeight)
// Don't let tall, thin images take up too much vertical space,
// which could be annoying to scroll through…
BoxConstraints(maxHeight: maxHeight)

This seems like it matches the comment better. And I believe it has the exact same effect, producing the same result for the .enforce call below.

@chrisbobbe
Copy link
Collaborator Author

Thanks for the review! Revision pushed, and this one is atop #2100 which I've just sent separately.

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! One substantive comment and one follow-up.

Comment on lines 1259 to 1260
final alt = imgElement.attributes['alt'];
if (alt == null) return null;
Copy link
Member

Choose a reason for hiding this comment

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

Can this be made optional? If a server for some reason starts sending these without alt, it seems like we can tell how we'd want to display that.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Aha it's actually already optional in the content node class, but we don't need this null return here.

Comment on lines +1367 to +1368
if (resolvedOriginalSrc != null) {
result = GestureDetector(
onTap: () {
Navigator.of(context).push(getImageLightboxRoute(
Copy link
Member

Choose a reason for hiding this comment

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

Perhaps as a followup PR, so while this is still fresh in mind but without blocking us merging and releasing the feature: this InlineImage is doing very nearly the same things as the MessageImagePreview widget, especially here in the _buildContent method, but the code looks more different than it is. It'd be nice to refactor them to look more similar so that the real differences are easier to spot.

Ideally, the logic here in _buildContent would become a helper widget that's shared between InlineImage and MessageImagePreview.

@chrisbobbe
Copy link
Collaborator Author

Thanks! Revision pushed, PTAL, and I'll start looking at that followup now.

Comment on lines 1420 to 1421
child: Tooltip(
message: node.alt,
Copy link
Member

Choose a reason for hiding this comment

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

Is it OK here if node.alt is null?

It looks like Tooltip has an assert that would reject that. The parameter is nullable only because there's an alternative parameter richMessage.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Good catch; will fix.

@chrisbobbe
Copy link
Collaborator Author

chrisbobbe commented Jan 26, 2026

(Noting here so I don't forget—I believe I've debugged the lightbox-hero-not-working issue and will include the fix in my next revision.)

@chrisbobbe
Copy link
Collaborator Author

Thanks! Revision pushed.

final size = BoxConstraints(maxHeight: maxHeight)
.constrainSizeAndAttemptToPreserveAspectRatio(imageSize);

Widget child = _buildContent(context, size: size);
Copy link
Member

Choose a reason for hiding this comment

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

This version is missing a ColoredBox, no?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Eep, yes, thanks for the catch.

},
child: LightboxHero(
messageImageContext: context,
src: resolvedOriginalSrc,
Copy link
Member

Choose a reason for hiding this comment

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

Aha — so the issue with the hero here was that it needed src to match that passed to getImageLightboxRoute.

@chrisbobbe
Copy link
Collaborator Author

Yep, that's right! Revision pushed.

@gnprice
Copy link
Member

gnprice commented Jan 26, 2026

Thanks! All looks good; merging.

@gnprice gnprice merged commit 5453fc7 into zulip:main Jan 26, 2026
1 check passed
@chrisbobbe chrisbobbe deleted the pr-inline-images branch January 26, 2026 22:17
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 product review Added by maintainers when a PR needs product review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

content: Handle inline images

3 participants