Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 93 additions & 8 deletions lib/model/content.dart
Original file line number Diff line number Diff line change
Expand Up @@ -540,6 +540,7 @@ sealed class ImageNode extends ContentNode {
const ImageNode({
super.debugHtmlNode,
required this.loading,
required this.alt,
required this.src,
required this.originalSrc,
required this.originalWidth,
Expand All @@ -555,6 +556,8 @@ sealed class ImageNode extends ContentNode {
/// Clients are invited to show a custom loading indicator instead; we do.
final bool loading;

final String? alt;

/// A URL for the image intended to be shown here in Zulip content.
///
/// If [loading] is true, this will point to a "spinner" image.
Expand Down Expand Up @@ -592,10 +595,31 @@ sealed class ImageNode extends ContentNode {
/// The height part of data-original-dimensions, if that attribute is present.
final double? originalHeight;

@override
bool operator ==(Object other) {
return other is ImageNode
&& other.loading == loading
&& other.alt == alt
&& other.src == src
&& other.originalSrc == originalSrc
&& other.originalWidth == originalWidth
&& other.originalHeight == originalHeight;
}

@override
int get hashCode => Object.hash('ImageNode',
loading,
alt,
src,
originalSrc,
originalWidth,
originalHeight);

@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(FlagProperty('loading', value: loading, ifTrue: "is loading"));
properties.add(StringProperty('alt', alt));
properties.add(DiagnosticsProperty<ImageNodeSrc>('src', src));
properties.add(StringProperty('originalSrc', originalSrc));
properties.add(DoubleProperty('originalWidth', originalWidth));
Expand All @@ -611,21 +635,16 @@ class ImagePreviewNode extends ImageNode implements BlockContentNode {
required super.originalSrc,
required super.originalWidth,
required super.originalHeight,
});
}) : super(alt: null);

@override
bool operator ==(Object other) {
return other is ImagePreviewNode
&& other.loading == loading
&& other.src == src
&& other.originalSrc == originalSrc
&& other.originalWidth == originalWidth
&& other.originalHeight == originalHeight;
&& super == other;
}

@override
int get hashCode => Object.hash('ImagePreviewNode',
loading, src, originalSrc, originalWidth, originalHeight);
int get hashCode => Object.hash('ImagePreviewNode', super.hashCode);
}

/// A value of [ImagePreviewNode.src].
Expand Down Expand Up @@ -1115,6 +1134,41 @@ class ImageEmojiNode extends EmojiNode {
}
}

/// An "inline image" / "Markdown-style image" node,
/// from the ![alt text](url) syntax.
///
/// See `api_docs/message-formatting.md` in the web PR for this feature:
/// https://github.com/zulip/zulip/pull/36226
///
/// This class accommodates forms not expected from servers in 2026-01,
/// to avoid being a landmine for possible future servers that send such forms.
/// Notably, in 2026-01, servers are expected to produce this content
/// just for uploaded images, which means the images' dimensions are available.
/// UI code should nevertheless do something reasonable when the dimensions
/// are not available. Discussion:
/// https://chat.zulip.org/#narrow/channel/378-api-design/topic/HTML.20pattern.20for.20truly.20inline.20images/near/2348085
// TODO: Link to the merged API doc when it lands.
class InlineImageNode extends ImageNode implements InlineContentNode {
const InlineImageNode({
super.debugHtmlNode,
required super.loading,
required super.alt,
required super.src,
required super.originalSrc,
required super.originalWidth,
required super.originalHeight,
});

@override
bool operator ==(Object other) {
return other is InlineImageNode
&& super == other;
}

@override
int get hashCode => Object.hash('InlineImageNode', super.hashCode);
}

class MathInlineNode extends MathNode implements InlineContentNode {
const MathInlineNode({
super.debugHtmlNode,
Expand Down Expand Up @@ -1189,6 +1243,28 @@ final _imageDimensionsRegExp = RegExp(r'^(\d+)x(\d+)$');
/// instance has been reset to its starting state, and can be re-used for
/// parsing other subtrees.
class _ZulipInlineContentParser {
InlineContentNode? parseInlineImage(dom.Element imgElement, {required bool loading}) {
assert(imgElement.localName == 'img');
assert(imgElement.className.contains('inline-image'));
assert(loading == imgElement.className.contains('image-loading-placeholder'));

final src = _tryParseImgSrc(imgElement);
if (src == null) return null;
final originalSrc = imgElement.attributes['data-original-src'];
final originalDimensions = _tryParseOriginalDimensions(imgElement);

final alt = imgElement.attributes['alt'];

return InlineImageNode(
loading: loading,
src: src,
alt: alt,
originalSrc: originalSrc,
originalWidth: originalDimensions?.originalWidth,
originalHeight: originalDimensions?.originalHeight,
);
}

InlineContentNode? parseInlineMath(dom.Element element) {
final debugHtmlNode = kDebugMode ? element : null;
final parsed = parseMath(element, block: false);
Expand Down Expand Up @@ -1340,6 +1416,15 @@ class _ZulipInlineContentParser {
if (src == null) return unimplemented();
return ImageEmojiNode(src: src, alt: alt, debugHtmlNode: debugHtmlNode);
}

if (className == 'inline-image') {
return parseInlineImage(element, loading: false) ?? unimplemented();
} else if (
className == 'inline-image image-loading-placeholder'
|| className == 'image-loading-placeholder inline-image'
) {
return parseInlineImage(element, loading: true) ?? unimplemented();
}
}

if (localName == 'time' && className.isEmpty) {
Expand Down
112 changes: 112 additions & 0 deletions lib/widgets/content.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1163,6 +1163,10 @@ class _InlineContentBuilder {
return WidgetSpan(alignment: PlaceholderAlignment.middle,
child: MessageImageEmoji(node: node));

case InlineImageNode():
return WidgetSpan(alignment: PlaceholderAlignment.middle,
child: InlineImage(node: node, ambientTextStyle: widget.style));

case MathInlineNode():
final nodes = node.nodes;
return nodes == null
Expand Down Expand Up @@ -1320,6 +1324,114 @@ class MessageImageEmoji extends StatelessWidget {
}
}

class InlineImage extends StatelessWidget {
const InlineImage({
super.key,
required this.node,
required this.ambientTextStyle,
});

final InlineImageNode node;
final TextStyle ambientTextStyle;

Widget _buildContent(BuildContext context, {required Size size}) {
final store = PerAccountStoreWidget.of(context);

if (node.loading) {
return CupertinoActivityIndicator();
}

final src = node.src;
final resolvedSrc = switch (src) {
ImageNodeSrcThumbnail() => src.value.resolve(context,
width: size.width,
height: size.height,
animationMode: .animateConditionally),
ImageNodeSrcOther() => store.tryResolveUrl(src.value),
};
if (resolvedSrc == null) {
// TODO(#265) use an error-case placeholder here
return SizedBox.shrink();
}

Widget result;
result = RealmContentNetworkImage(
// TODO(#265) use an error-case placeholder for `errorBuilder`
semanticLabel: node.alt,
resolvedSrc);

final resolvedOriginalSrc = node.originalSrc == null ? null
: store.tryResolveUrl(node.originalSrc!);
if (resolvedOriginalSrc != null) {
result = GestureDetector(
onTap: () {
Navigator.of(context).push(getImageLightboxRoute(
Comment on lines +1365 to +1368
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.

context: context,
message: InheritedMessage.of(context),
messageImageContext: context,
src: resolvedOriginalSrc,
thumbnailUrl: resolvedSrc,
originalWidth: node.originalWidth,
originalHeight: node.originalHeight));
},
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.

child: result));
}

return result;
}

@override
Widget build(BuildContext context) {
final devicePixelRatio = MediaQuery.devicePixelRatioOf(context);

// Follow web's max-height behavior (10em);
// see image_box_em in web/src/postprocess_content.ts.
final maxHeight = ambientTextStyle.fontSize! * 10;

final imageSize = (node.originalWidth != null && node.originalHeight != null)
? Size(node.originalWidth!, node.originalHeight!) / devicePixelRatio
// Layout plan when original dimensions are unknown:
// a [MessageMediaContainer]-sized and -colored rectangle.
: Size(MessageMediaContainer.width, MessageMediaContainer.height);

// (a) Don't let tall, thin images take up too much vertical space,
// which could be annoying to scroll through. And:
// (b) Don't let small images grow to occupy more physical pixels
// than they have data for.
// It looks like web has code for this in web/src/postprocess_content.ts
// but it doesn't account for the device pixel ratio, in 2026-01.
// So in web, small images do get blown up and blurry on modern devices:
// https://chat.zulip.org/#narrow/channel/101-design/topic/Inline.20images.20blown.20up.20and.20blurry/near/2346831
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.

if (node.alt != null) {
child = Tooltip(
message: node.alt,
// (Instead of setting a semantics label here,
// we give the alt text to [RealmContentNetworkImage].)
excludeFromSemantics: true,
child: child);
}

return Padding(
// Separate images vertically when they flow onto separate lines.
// (3px follows web; see web/styles/rendered_markdown.css.)
padding: const EdgeInsets.only(top: 3),
child: ConstrainedBox(
constraints: BoxConstraints.loose(size),
child: AspectRatio(
aspectRatio: size.aspectRatio,
child: ColoredBox(
color: ContentTheme.of(context).colorMessageMediaContainerBackground,
child: child))));
}
}

class GlobalTime extends StatelessWidget {
const GlobalTime({
super.key,
Expand Down
Loading