Skip to content

Commit

Permalink
content: Handle clusters of images in parseImplicitParagraphBlockCont…
Browse files Browse the repository at this point in the history
…entList

Fixes: zulip#193
  • Loading branch information
sirpengi committed Feb 9, 2024
1 parent ab9dd8e commit a6d96a3
Show file tree
Hide file tree
Showing 4 changed files with 123 additions and 2 deletions.
26 changes: 25 additions & 1 deletion lib/model/content.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1016,6 +1016,7 @@ class _ZulipContentParser {
assert(_debugParserContext == _ParserContext.block);
final List<BlockContentNode> result = [];
final List<dom.Node> currentParagraph = [];
List<ImageNode> imageNodes = [];
void consumeParagraph() {
final parsed = parseBlockInline(currentParagraph);
result.add(ParagraphNode(
Expand All @@ -1029,13 +1030,36 @@ 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(ParagraphNode(
wasImplicit: true,
links: null,
nodes: [UnimplementedInlineContentNode(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;
}
Expand Down
4 changes: 4 additions & 0 deletions lib/widgets/content.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
70 changes: 70 additions & 0 deletions test/model/content_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,73 @@ 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",
'<ul>\n'
'<li>'
'<div class="message_inline_image">'
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png">'
'<img src="https://chat.zulip.org/user_avatars/2/realm/icon.png"></a></div></li>\n</ul>', [
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)",
'<ul>\n'
'<li>'
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png">icon.png</a> '
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=2">icon.png</a>'
'<div class="message_inline_image">'
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png" title="icon.png">'
'<img src="https://chat.zulip.org/user_avatars/2/realm/icon.png"></a></div>'
'<div class="message_inline_image">'
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=2" title="icon.png">'
'<img src="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=2"></a></div></li>\n</ul>', [
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 contentAfterImageClusterInImplicitParagraph = 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,
'<ul>\n'
'<li>'
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png">icon.png</a> '
'<div class="message_inline_image">'
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png" title="icon.png">'
'<img src="https://chat.zulip.org/user_avatars/2/realm/icon.png"></a></div>'
'<span>Some content</span></li>\n</ul>', [
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'),
]),
ParagraphNode(wasImplicit: true, links: null, nodes: [
inlineUnimplemented('<span>Some content</span>'),
])
]]),
]);
}

UnimplementedBlockContentNode blockUnimplemented(String html) {
Expand Down Expand Up @@ -685,6 +752,9 @@ void main() {
testParseExample(ContentExample.multipleImages);
testParseExample(ContentExample.contentAfterImageCluster);
testParseExample(ContentExample.multipleImageClusters);
testParseExample(ContentExample.imageInImplicitParagraph);
testParseExample(ContentExample.imageClusterInImplicitParagraph);
testParseExample(ContentExample.contentAfterImageClusterInImplicitParagraph);

testParse('parse nested lists, quotes, headings, code blocks',
// "1. > ###### two\n > * three\n\n four"
Expand Down
25 changes: 24 additions & 1 deletion test/widgets/content_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,30 @@ void main() {
await prepareContent(tester, example.html);
final expectedImages = (example.expectedNodes[1] as ImageNodeList).images
+ (example.expectedNodes[4] as ImageNodeList).images;
final images = tester.widgetList<RealmContentNetworkImage>(find.byType(RealmContentNetworkImage));
final images = tester.widgetList<RealmContentNetworkImage>(
find.byType(RealmContentNetworkImage));
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<RealmContentNetworkImage>(
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<RealmContentNetworkImage>(
find.byType(RealmContentNetworkImage));
check(images.map((i) => i.src.toString()).toList())
.deepEquals(expectedImages.map((n) => n.srcUrl));
});
Expand Down

0 comments on commit a6d96a3

Please sign in to comment.