diff --git a/lib/model/content.dart b/lib/model/content.dart index f4d0faf44c..19317c2db9 100644 --- a/lib/model/content.dart +++ b/lib/model/content.dart @@ -1016,6 +1016,7 @@ class _ZulipContentParser { assert(_debugParserContext == _ParserContext.block); final List result = []; final List currentParagraph = []; + List imageNodes = []; void consumeParagraph() { final parsed = parseBlockInline(currentParagraph); result.add(ParagraphNode( @@ -1029,13 +1030,31 @@ class _ZulipContentParser { if (node is dom.Text && (node.text == '\n')) continue; if (_isPossibleInlineNode(node)) { + if (imageNodes.isNotEmpty) { + result.add(ImageNodeList(imageNodes)); + imageNodes = []; + // In a context where paragraphs are implicit it should be impossible + // to have more paragraph content after image previews. + result.add(UnimplementedBlockContentNode(htmlNode: node)); + continue; + } currentParagraph.add(node); continue; } if (currentParagraph.isNotEmpty) consumeParagraph(); - result.add(parseBlockContent(node)); + final block = parseBlockContent(node); + if (block is ImageNode) { + imageNodes.add(block); + continue; + } + if (imageNodes.isNotEmpty) { + result.add(ImageNodeList(imageNodes)); + imageNodes = []; + } + result.add(block); } if (currentParagraph.isNotEmpty) consumeParagraph(); + if (imageNodes.isNotEmpty) result.add(ImageNodeList(imageNodes)); return result; } diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index f22108ec15..e2a45923eb 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -87,6 +87,10 @@ class BlockContentList extends StatelessWidget { } else if (node is ImageNodeList) { return MessageImageList(node: node); } else if (node is ImageNode) { + assert(false, + "[ImageNode] not allowed in [BlockContentList]. " + "It should be wrapped in [ImageNodeList]." + ); return MessageImage(node: node); } else if (node is UnimplementedBlockContentNode) { return Text.rich(_errorUnimplemented(node)); diff --git a/test/model/content_test.dart b/test/model/content_test.dart index 9819592ced..d79dd98910 100644 --- a/test/model/content_test.dart +++ b/test/model/content_test.dart @@ -357,6 +357,71 @@ class ContentExample { ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/51b70540cf6a5b3c8a0b919c893b8abddd447e88/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d33'), ]), ]); + + static const imageInImplicitParagraph = ContentExample( + 'image as immediate child in implicit paragraph', + "* https://chat.zulip.org/user_avatars/2/realm/icon.png", + '', [ + ListNode(ListStyle.unordered, [[ + ImageNodeList([ + ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png'), + ]), + ]]), + ]); + + static const imageClusterInImplicitParagraph = ContentExample( + 'image cluster in implicit paragraph', + "* [icon.png](https://chat.zulip.org/user_avatars/2/realm/icon.png) [icon.png](https://chat.zulip.org/user_avatars/2/realm/icon.png?version=2)", + '', [ + ListNode(ListStyle.unordered, [[ + ParagraphNode(wasImplicit: true, links: null, nodes: [ + LinkNode(url: 'https://chat.zulip.org/user_avatars/2/realm/icon.png', nodes: [TextNode('icon.png')]), + TextNode(' '), + LinkNode(url: 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=2', nodes: [TextNode('icon.png')]), + ]), + ImageNodeList([ + ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png'), + ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=2'), + ]), + ]]), + ]); + + static final imageClusterInImplicitParagraphThenContent = ContentExample( + 'impossible content after image cluster in implicit paragraph', + // Image previews are always inserted at the end of the paragraph + // so it would be impossible to have content after. + null, + '', [ + ListNode(ListStyle.unordered, [[ + const ParagraphNode(wasImplicit: true, links: null, nodes: [ + LinkNode(url: 'https://chat.zulip.org/user_avatars/2/realm/icon.png', nodes: [TextNode('icon.png')]), + TextNode(' '), + ]), + const ImageNodeList([ + ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png'), + ]), + blockUnimplemented('more text'), + ]]), + ]); } UnimplementedBlockContentNode blockUnimplemented(String html) { @@ -685,6 +750,9 @@ void main() { testParseExample(ContentExample.imageCluster); testParseExample(ContentExample.imageClusterThenContent); testParseExample(ContentExample.imageMultipleClusters); + testParseExample(ContentExample.imageInImplicitParagraph); + testParseExample(ContentExample.imageClusterInImplicitParagraph); + testParseExample(ContentExample.imageClusterInImplicitParagraphThenContent); testParse('parse nested lists, quotes, headings, code blocks', // "1. > ###### two\n > * three\n\n four" diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index dca6096c40..673e8c27ca 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -319,6 +319,28 @@ void main() { check(images.map((i) => i.src.toString()).toList()) .deepEquals(expectedImages.map((n) => n.srcUrl)); }); + + testWidgets('image as immediate child in implicit paragraph', (tester) async { + const example = ContentExample.imageInImplicitParagraph; + await prepareContent(tester, example.html); + final expectedImages = ((example.expectedNodes[0] as ListNode) + .items[0][0] as ImageNodeList).images; + final images = tester.widgetList( + find.byType(RealmContentNetworkImage)); + check(images.map((i) => i.src.toString()).toList()) + .deepEquals(expectedImages.map((n) => n.srcUrl)); + }); + + testWidgets('image cluster in implicit paragraph', (tester) async { + const example = ContentExample.imageClusterInImplicitParagraph; + await prepareContent(tester, example.html); + final expectedImages = ((example.expectedNodes[0] as ListNode) + .items[0][1] as ImageNodeList).images; + final images = tester.widgetList( + find.byType(RealmContentNetworkImage)); + check(images.map((i) => i.src.toString()).toList()) + .deepEquals(expectedImages.map((n) => n.srcUrl)); + }); }); group('RealmContentNetworkImage', () {