diff --git a/lib/model/content.dart b/lib/model/content.dart index 094b74bcb1..42f95f1e9e 100644 --- a/lib/model/content.dart +++ b/lib/model/content.dart @@ -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, @@ -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. @@ -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('src', src)); properties.add(StringProperty('originalSrc', originalSrc)); properties.add(DoubleProperty('originalWidth', originalWidth)); @@ -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]. @@ -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, @@ -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); @@ -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) { diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index cefbd23ed3..43bcad1944 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -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 @@ -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( + context: context, + message: InheritedMessage.of(context), + messageImageContext: context, + src: resolvedOriginalSrc, + thumbnailUrl: resolvedSrc, + originalWidth: node.originalWidth, + originalHeight: node.originalHeight)); + }, + child: LightboxHero( + messageImageContext: context, + src: resolvedOriginalSrc, + 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); + 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, diff --git a/test/model/content_test.dart b/test/model/content_test.dart index bd8721c782..d593521a3a 100644 --- a/test/model/content_test.dart +++ b/test/model/content_test.dart @@ -260,6 +260,78 @@ class ContentExample { const ImageEmojiNode( src: '/static/generated/emoji/images/emoji/unicode/zulip.png', alt: ':zulip:')); + static final inlineImage = ContentExample.inline( + 'inline image', + '![image.png](/user_uploads/2/15/nO8mls-ZGl6LBC9bRNVL2jAG/image.png)', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Chris/near/2346984 + '

image.png

', + InlineImageNode( + loading: false, + src: ImageNodeSrcThumbnail(ImageThumbnailLocator(animated: false, + defaultFormatSrc: Uri.parse('/user_uploads/thumbnail/2/15/nO8mls-ZGl6LBC9bRNVL2jAG/image.png/840x560.webp'))), + alt: 'image.png', + originalSrc: '/user_uploads/2/15/nO8mls-ZGl6LBC9bRNVL2jAG/image.png', + originalWidth: 186, + originalHeight: 142)); + + static final inlineImageLoading = ContentExample.inline( + 'inline image, loading', + '![image.png](/user_uploads/2/15/nO8mls-ZGl6LBC9bRNVL2jAG/image.png)', + // HTML constructed from the API doc in the pull request for this feature: + // https://github.com/zulip/zulip/pull/36226 + '

example image

', + InlineImageNode( + loading: true, + src: ImageNodeSrcOther('/path/to/spinner.png'), + alt: 'example image', + originalSrc: '/user_uploads/path/to/example.png', + originalWidth: 1050, + originalHeight: 700)); + + static final inlineImageLoadingClassOrderReversed = ContentExample.inline( + 'inline image, loading, class order reversed', + '![image.png](/user_uploads/2/15/nO8mls-ZGl6LBC9bRNVL2jAG/image.png)', + // Hypothetical server variation on inlineImageLoading. + '

example image

', + InlineImageNode( + loading: true, + src: ImageNodeSrcOther('/path/to/spinner.png'), + alt: 'example image', + originalSrc: '/user_uploads/path/to/example.png', + originalWidth: 1050, + originalHeight: 700)); + + static final inlineImageAnimated = ContentExample.inline( + 'inline image, animated', + '![2451eb2d.gif](/user_uploads/2/1a/igMNAkwVOP7NLJy-Hye6WiKP/2451eb2d.gif)', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Chris/near/2346992 + '

2451eb2d.gif

', + InlineImageNode( + loading: false, + src: ImageNodeSrcThumbnail(ImageThumbnailLocator(animated: true, + defaultFormatSrc: Uri.parse('/user_uploads/thumbnail/2/1a/igMNAkwVOP7NLJy-Hye6WiKP/2451eb2d.gif/840x560-anim.webp'))), + alt: '2451eb2d.gif', + originalSrc: '/user_uploads/2/1a/igMNAkwVOP7NLJy-Hye6WiKP/2451eb2d.gif', + originalWidth: 64, + originalHeight: 64)); + static final globalTime = ContentExample.inline( 'global time', "", @@ -1563,6 +1635,37 @@ class ContentExample { ]), ]); + static final tableWithInlineImage = ContentExample( + 'table with inline image', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Chris/near/2347028 + '| a |\n| - |\n| ![image2.jpg](/user_uploads/2/6f/KS3vNT9c2tbMfMBkSbQF_Jlj/image2.jpg) |', + '\n\n\n\n\n\n' + '\n\n\n\n\n
a
' + 'image2.jpg' + '
', [ + TableNode(rows: [ + TableRowNode(cells: [ + TableCellNode(nodes: [TextNode('a')], links: [], textAlignment: TableColumnTextAlignment.defaults), + ], isHeader: true), + TableRowNode(cells: [ + TableCellNode(nodes: [ + InlineImageNode( + loading: false, + src: ImageNodeSrcThumbnail(ImageThumbnailLocator(animated: false, + defaultFormatSrc: Uri.parse('/user_uploads/thumbnail/2/6f/KS3vNT9c2tbMfMBkSbQF_Jlj/image2.jpg/840x560.webp'))), + alt: 'image2.jpg', + originalSrc: '/user_uploads/2/6f/KS3vNT9c2tbMfMBkSbQF_Jlj/image2.jpg', + originalWidth: 2760, + originalHeight: 4912), + ], links: [], textAlignment: TableColumnTextAlignment.defaults), + ], isHeader: false), + ]), + ]); + // As is, this HTML doesn't look particularly different to our parser. // But if Zulip's table support followed GFM, this would have no : // https://github.github.com/gfm/#example-205 @@ -1790,6 +1893,12 @@ void main() async { testParseExample(ContentExample.emojiCustomInvalidUrl); testParseExample(ContentExample.emojiZulipExtra); + testParseExample(ContentExample.inlineImage); + testParseExample(ContentExample.inlineImageLoading); + testParseExample(ContentExample.inlineImageLoadingClassOrderReversed); + testParseExample(ContentExample.inlineImageAnimated); + testParseExample(ContentExample.tableWithInlineImage); + testParseExample(ContentExample.mathInline); testParseExample(ContentExample.mathInlineUnknown); diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index d6eca1d49b..c303f37da5 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -1484,6 +1484,53 @@ void main() { }); }); + group('InlineImage', () { + late TransitionDurationObserver transitionDurationObserver; + + Future prepare(WidgetTester tester, String html) async { + transitionDurationObserver = TransitionDurationObserver(); + await prepareContent(tester, + // Message is needed for the image's lightbox. + messageContent(html), + navObservers: [transitionDurationObserver], + // We try to resolve the image's URL on the self-account's realm. + wrapWithPerAccountStoreWidget: true); + } + + testWidgets('smoke: inline image', (tester) async { + await prepare(tester, ContentExample.inlineImage.html); + check(find.byType(InlineImage)).findsOne(); + + prepareBoringImageHttpClient(); + await tester.tap(find.byType(InlineImage)); + await transitionDurationObserver.pumpPastTransition(tester); + check(find.byType(InteractiveViewer)).findsOne(); // recognize the lightbox + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('smoke: inline image, loading', (tester) async { + await prepare(tester, ContentExample.inlineImageLoading.html); + check(find.byType(InlineImage)).findsOne(); + check(find.byType(CupertinoActivityIndicator)).findsOne(); + }); + + testWidgets('smoke: inline image, animated', (tester) async { + await prepare(tester, ContentExample.inlineImageAnimated.html); + check(find.byType(InlineImage)).findsOne(); + }); + + testWidgets('table with inline image', (tester) async { + await prepare(tester, ContentExample.tableWithInlineImage.html); + check(find.byType(InlineImage)).findsOne(); + + prepareBoringImageHttpClient(); + await tester.tap(find.byType(InlineImage)); + await transitionDurationObserver.pumpPastTransition(tester); + check(find.byType(InteractiveViewer)).findsOne(); // recognize the lightbox + debugNetworkImageHttpClientProvider = null; + }); + }); + group('WebsitePreview', () { Future prepare(WidgetTester tester, String html) async { await prepareContent(tester, plainContent(html),