Skip to content

Commit e121da4

Browse files
committed
content: Handle clusters of images in parseImplicitParagraphBlockContentList
Fixes: zulip#193
1 parent be2df9e commit e121da4

File tree

4 files changed

+114
-1
lines changed

4 files changed

+114
-1
lines changed

lib/model/content.dart

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1016,6 +1016,7 @@ class _ZulipContentParser {
10161016
assert(_debugParserContext == _ParserContext.block);
10171017
final List<BlockContentNode> result = [];
10181018
final List<dom.Node> currentParagraph = [];
1019+
List<ImageNode> imageNodes = [];
10191020
void consumeParagraph() {
10201021
final parsed = parseBlockInline(currentParagraph);
10211022
result.add(ParagraphNode(
@@ -1029,13 +1030,31 @@ class _ZulipContentParser {
10291030
if (node is dom.Text && (node.text == '\n')) continue;
10301031

10311032
if (_isPossibleInlineNode(node)) {
1033+
if (imageNodes.isNotEmpty) {
1034+
result.add(ImageNodeList(imageNodes));
1035+
imageNodes = [];
1036+
// In a context where paragraphs are implicit it should be impossible
1037+
// to have more paragraph content after image previews.
1038+
result.add(UnimplementedBlockContentNode(htmlNode: node));
1039+
continue;
1040+
}
10321041
currentParagraph.add(node);
10331042
continue;
10341043
}
10351044
if (currentParagraph.isNotEmpty) consumeParagraph();
1036-
result.add(parseBlockContent(node));
1045+
final block = parseBlockContent(node);
1046+
if (block is ImageNode) {
1047+
imageNodes.add(block);
1048+
continue;
1049+
}
1050+
if (imageNodes.isNotEmpty) {
1051+
result.add(ImageNodeList(imageNodes));
1052+
imageNodes = [];
1053+
}
1054+
result.add(block);
10371055
}
10381056
if (currentParagraph.isNotEmpty) consumeParagraph();
1057+
if (imageNodes.isNotEmpty) result.add(ImageNodeList(imageNodes));
10391058

10401059
return result;
10411060
}

lib/widgets/content.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,10 @@ class BlockContentList extends StatelessWidget {
8787
} else if (node is ImageNodeList) {
8888
return MessageImageList(node: node);
8989
} else if (node is ImageNode) {
90+
assert(false,
91+
"[ImageNode] not allowed in [BlockContentList]. "
92+
"It should be wrapped in [ImageNodeList]."
93+
);
9094
return MessageImage(node: node);
9195
} else if (node is UnimplementedBlockContentNode) {
9296
return Text.rich(_errorUnimplemented(node));

test/model/content_test.dart

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,71 @@ class ContentExample {
357357
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/51b70540cf6a5b3c8a0b919c893b8abddd447e88/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d33'),
358358
]),
359359
]);
360+
361+
static const imageInImplicitParagraph = ContentExample(
362+
'image as immediate child in implicit paragraph',
363+
"* https://chat.zulip.org/user_avatars/2/realm/icon.png",
364+
'<ul>\n'
365+
'<li>'
366+
'<div class="message_inline_image">'
367+
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png">'
368+
'<img src="https://chat.zulip.org/user_avatars/2/realm/icon.png"></a></div></li>\n</ul>', [
369+
ListNode(ListStyle.unordered, [[
370+
ImageNodeList([
371+
ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png'),
372+
]),
373+
]]),
374+
]);
375+
376+
static const imageClusterInImplicitParagraph = ContentExample(
377+
'image cluster in implicit paragraph',
378+
"* [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)",
379+
'<ul>\n'
380+
'<li>'
381+
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png">icon.png</a> '
382+
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=2">icon.png</a>'
383+
'<div class="message_inline_image">'
384+
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png" title="icon.png">'
385+
'<img src="https://chat.zulip.org/user_avatars/2/realm/icon.png"></a></div>'
386+
'<div class="message_inline_image">'
387+
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=2" title="icon.png">'
388+
'<img src="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=2"></a></div></li>\n</ul>', [
389+
ListNode(ListStyle.unordered, [[
390+
ParagraphNode(wasImplicit: true, links: null, nodes: [
391+
LinkNode(url: 'https://chat.zulip.org/user_avatars/2/realm/icon.png', nodes: [TextNode('icon.png')]),
392+
TextNode(' '),
393+
LinkNode(url: 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=2', nodes: [TextNode('icon.png')]),
394+
]),
395+
ImageNodeList([
396+
ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png'),
397+
ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=2'),
398+
]),
399+
]]),
400+
]);
401+
402+
static final imageClusterInImplicitParagraphThenContent = ContentExample(
403+
'impossible content after image cluster in implicit paragraph',
404+
// Image previews are always inserted at the end of the paragraph
405+
// so it would be impossible to have content after.
406+
null,
407+
'<ul>\n'
408+
'<li>'
409+
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png">icon.png</a> '
410+
'<div class="message_inline_image">'
411+
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png" title="icon.png">'
412+
'<img src="https://chat.zulip.org/user_avatars/2/realm/icon.png"></a></div>'
413+
'more text</li>\n</ul>', [
414+
ListNode(ListStyle.unordered, [[
415+
const ParagraphNode(wasImplicit: true, links: null, nodes: [
416+
LinkNode(url: 'https://chat.zulip.org/user_avatars/2/realm/icon.png', nodes: [TextNode('icon.png')]),
417+
TextNode(' '),
418+
]),
419+
const ImageNodeList([
420+
ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png'),
421+
]),
422+
blockUnimplemented('more text'),
423+
]]),
424+
]);
360425
}
361426

362427
UnimplementedBlockContentNode blockUnimplemented(String html) {
@@ -685,6 +750,9 @@ void main() {
685750
testParseExample(ContentExample.imageCluster);
686751
testParseExample(ContentExample.imageClusterThenContent);
687752
testParseExample(ContentExample.imageMultipleClusters);
753+
testParseExample(ContentExample.imageInImplicitParagraph);
754+
testParseExample(ContentExample.imageClusterInImplicitParagraph);
755+
testParseExample(ContentExample.imageClusterInImplicitParagraphThenContent);
688756

689757
testParse('parse nested lists, quotes, headings, code blocks',
690758
// "1. > ###### two\n > * three\n\n four"

test/widgets/content_test.dart

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,28 @@ void main() {
319319
check(images.map((i) => i.src.toString()).toList())
320320
.deepEquals(expectedImages.map((n) => n.srcUrl));
321321
});
322+
323+
testWidgets('image as immediate child in implicit paragraph', (tester) async {
324+
const example = ContentExample.imageInImplicitParagraph;
325+
await prepareContent(tester, example.html);
326+
final expectedImages = ((example.expectedNodes[0] as ListNode)
327+
.items[0][0] as ImageNodeList).images;
328+
final images = tester.widgetList<RealmContentNetworkImage>(
329+
find.byType(RealmContentNetworkImage));
330+
check(images.map((i) => i.src.toString()).toList())
331+
.deepEquals(expectedImages.map((n) => n.srcUrl));
332+
});
333+
334+
testWidgets('image cluster in implicit paragraph', (tester) async {
335+
const example = ContentExample.imageClusterInImplicitParagraph;
336+
await prepareContent(tester, example.html);
337+
final expectedImages = ((example.expectedNodes[0] as ListNode)
338+
.items[0][1] as ImageNodeList).images;
339+
final images = tester.widgetList<RealmContentNetworkImage>(
340+
find.byType(RealmContentNetworkImage));
341+
check(images.map((i) => i.src.toString()).toList())
342+
.deepEquals(expectedImages.map((n) => n.srcUrl));
343+
});
322344
});
323345

324346
group('RealmContentNetworkImage', () {

0 commit comments

Comments
 (0)