From ab9dd8e544ed539f0d42041b21b2dd71c74d8124 Mon Sep 17 00:00:00 2001
From: Shu Chen
Date: Mon, 18 Dec 2023 20:41:33 +0000
Subject: [PATCH] content: Handle clusters of images in parseBlockContentList
---
lib/model/content.dart | 34 +++++++--
lib/widgets/content.dart | 17 ++++-
test/model/content_test.dart | 121 ++++++++++++++++++++++++++++++---
test/widgets/content_test.dart | 70 +++++++++++++++++++
4 files changed, 227 insertions(+), 15 deletions(-)
diff --git a/lib/model/content.dart b/lib/model/content.dart
index 925a3e64f32..f4d0faf44c0 100644
--- a/lib/model/content.dart
+++ b/lib/model/content.dart
@@ -309,6 +309,17 @@ class MathBlockNode extends BlockContentNode {
}
}
+class ImageNodeList extends BlockContentNode {
+ const ImageNodeList(this.images, {super.debugHtmlNode});
+
+ final List images;
+
+ @override
+ List debugDescribeChildren() {
+ return images.map((node) => node.toDiagnosticsNode()).toList();
+ }
+}
+
class ImageNode extends BlockContentNode {
const ImageNode({super.debugHtmlNode, required this.srcUrl});
@@ -1031,13 +1042,26 @@ class _ZulipContentParser {
List parseBlockContentList(dom.NodeList nodes) {
assert(_debugParserContext == _ParserContext.block);
- final acceptedNodes = nodes.where((node) {
+ final List result = [];
+ List imageNodes = [];
+ for (final node in nodes) {
// We get a bunch of newline Text nodes between paragraphs.
// A browser seems to ignore these; let's do the same.
- if (node is dom.Text && (node.text == '\n')) return false;
- return true;
- });
- return acceptedNodes.map(parseBlockContent).toList(growable: false);
+ if (node is dom.Text && (node.text == '\n')) continue;
+
+ final block = parseBlockContent(node);
+ if (block is ImageNode) {
+ imageNodes.add(block);
+ continue;
+ }
+ if (imageNodes.isNotEmpty) {
+ result.add(ImageNodeList(imageNodes));
+ imageNodes = [];
+ }
+ result.add(block);
+ }
+ if (imageNodes.isNotEmpty) result.add(ImageNodeList(imageNodes));
+ return result;
}
ZulipContent parse(String html) {
diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart
index db6dd5b6d6a..f22108ec150 100644
--- a/lib/widgets/content.dart
+++ b/lib/widgets/content.dart
@@ -84,6 +84,8 @@ class BlockContentList extends StatelessWidget {
return CodeBlock(node: node);
} else if (node is MathBlockNode) {
return MathBlock(node: node);
+ } else if (node is ImageNodeList) {
+ return MessageImageList(node: node);
} else if (node is ImageNode) {
return MessageImage(node: node);
} else if (node is UnimplementedBlockContentNode) {
@@ -230,6 +232,18 @@ class ListItemWidget extends StatelessWidget {
}
}
+class MessageImageList extends StatelessWidget {
+ const MessageImageList({super.key, required this.node});
+
+ final ImageNodeList node;
+
+ @override
+ Widget build(BuildContext context) {
+ return Wrap(
+ children: node.images.map((imageNode) => MessageImage(node: imageNode)).toList());
+ }
+}
+
class MessageImage extends StatelessWidget {
const MessageImage({super.key, required this.node});
@@ -239,7 +253,6 @@ class MessageImage extends StatelessWidget {
Widget build(BuildContext context) {
final message = InheritedMessage.of(context);
- // TODO(#193) multiple images in a row
// TODO image hover animation
final src = node.srcUrl;
@@ -251,7 +264,7 @@ class MessageImage extends StatelessWidget {
Navigator.of(context).push(getLightboxRoute(
context: context, message: message, src: resolvedSrc));
},
- child: Align(
+ child: UnconstrainedBox(
alignment: Alignment.centerLeft,
child: Padding(
// TODO clean up this padding by imitating web less precisely;
diff --git a/test/model/content_test.dart b/test/model/content_test.dart
index 70c940a93af..e4baaedc1d9 100644
--- a/test/model/content_test.dart
+++ b/test/model/content_test.dart
@@ -248,6 +248,115 @@ class ContentExample {
'λ'
'
\n
\n',
[QuotationNode([MathBlockNode(texSource: r'\lambda')])]);
+
+ static const singleImage = ContentExample(
+ 'single image',
+ "https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3",
+ '', [
+ ImageNodeList([
+ ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3'),
+ ]),
+ ]);
+
+ static const multipleImages = ContentExample(
+ 'multiple images',
+ "https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3\nhttps://chat.zulip.org/user_avatars/2/realm/icon.png?version=4",
+ ''
+ 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3
\n'
+ 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=4
\n'
+ ''
+ '', [
+ ParagraphNode(links: null, nodes: [
+ LinkNode(url: 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3', nodes: [TextNode('https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3')]),
+ LineBreakInlineNode(),
+ TextNode('\n'),
+ LinkNode(url: 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=4', nodes: [TextNode('https://chat.zulip.org/user_avatars/2/realm/icon.png?version=4')]),
+ ]),
+ ImageNodeList([
+ ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/f535ba07f95b99a83aa48e44fd62bbb6c6cf6615/68747470733a2f2f636861742e7a756c69702e6f72672f757365725f617661746172732f322f7265616c6d2f69636f6e2e706e673f76657273696f6e3d33'),
+ ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/8f63bc2632a0e41be3f457d86c077e61b4a03e7e/68747470733a2f2f636861742e7a756c69702e6f72672f757365725f617661746172732f322f7265616c6d2f69636f6e2e706e673f76657273696f6e3d34'),
+ ]),
+ ]);
+
+ static const contentAfterImageCluster = ContentExample(
+ 'content after image cluster',
+ "https://chat.zulip.org/user_avatars/2/realm/icon.png\nhttps://chat.zulip.org/user_avatars/2/realm/icon.png?version=2\n\nmore content",
+ 'content '
+ 'icon.png '
+ 'icon.png
\n'
+ ''
+ ''
+ 'more content
', [
+ ParagraphNode(links: null, nodes: [
+ TextNode('content '),
+ 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'),
+ ]),
+ ParagraphNode(links: null, nodes: [
+ TextNode('more content'),
+ ]),
+ ]);
+
+ static const multipleImageClusters = ContentExample(
+ 'multiple clusters of images',
+ "https://en.wikipedia.org/static/images/icons/wikipedia.png\nhttps://en.wikipedia.org/static/images/icons/wikipedia.png?v=1\n\nTest\n\nhttps://en.wikipedia.org/static/images/icons/wikipedia.png?v=2\nhttps://en.wikipedia.org/static/images/icons/wikipedia.png?v=3",
+ ''
+ 'https://en.wikipedia.org/static/images/icons/wikipedia.png
\n' 'https://en.wikipedia.org/static/images/icons/wikipedia.png?v=1
\n'
+ ''
+ ''
+ 'Test
\n'
+ ''
+ 'https://en.wikipedia.org/static/images/icons/wikipedia.png?v=2
\n'
+ 'https://en.wikipedia.org/static/images/icons/wikipedia.png?v=3
\n'
+ ''
+ '', [
+ ParagraphNode(links: null, nodes: [
+ LinkNode(url: 'https://en.wikipedia.org/static/images/icons/wikipedia.png', nodes: [TextNode('https://en.wikipedia.org/static/images/icons/wikipedia.png')]),
+ LineBreakInlineNode(),
+ TextNode('\n'),
+ LinkNode(url: 'https://en.wikipedia.org/static/images/icons/wikipedia.png?v=1', nodes: [TextNode('https://en.wikipedia.org/static/images/icons/wikipedia.png?v=1')]),
+ ]),
+ ImageNodeList([
+ ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/34b2695ca83af76204b0b25a8f2019ee35ec38fa/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e67'),
+ ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/d200fb112aaccbff9df767373a201fa59601f362/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d31'),
+ ]),
+ ParagraphNode(links: null, nodes: [
+ TextNode('Test'),
+ ]),
+ ParagraphNode(links: null, nodes: [
+ LinkNode(url: 'https://en.wikipedia.org/static/images/icons/wikipedia.png?v=2', nodes: [TextNode('https://en.wikipedia.org/static/images/icons/wikipedia.png?v=2')]),
+ LineBreakInlineNode(),
+ TextNode('\n'),
+ LinkNode(url: 'https://en.wikipedia.org/static/images/icons/wikipedia.png?v=3', nodes: [TextNode('https://en.wikipedia.org/static/images/icons/wikipedia.png?v=3')]),
+ ]),
+ ImageNodeList([
+ ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/c4db87e81348dac94eacaa966b46d968b34029cc/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d32'),
+ ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/51b70540cf6a5b3c8a0b919c893b8abddd447e88/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d33'),
+ ]),
+ ]);
}
UnimplementedBlockContentNode blockUnimplemented(String html) {
@@ -572,14 +681,10 @@ void main() {
testParseExample(ContentExample.mathBlock);
testParseExample(ContentExample.mathBlockInQuote);
- testParse('parse image',
- // "https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3"
- '', const [
- ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3'),
- ]);
+ testParseExample(ContentExample.singleImage);
+ testParseExample(ContentExample.multipleImages);
+ testParseExample(ContentExample.contentAfterImageCluster);
+ testParseExample(ContentExample.multipleImageClusters);
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 5e50fade834..b344566db00 100644
--- a/test/widgets/content_test.dart
+++ b/test/widgets/content_test.dart
@@ -250,6 +250,76 @@ void main() {
tester.widget(find.textContaining(RegExp(r'^(Tue, Jan 30|Wed, Jan 31), 2024, \d+:\d\d [AP]M$')));
});
+ group('MessageImages', () {
+ final message = eg.streamMessage();
+
+ Future prepareContent(WidgetTester tester, String html) async {
+ addTearDown(testBinding.reset);
+
+ await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot());
+ final httpClient = FakeImageHttpClient();
+
+ debugNetworkImageHttpClientProvider = () => httpClient;
+ httpClient.request.response
+ ..statusCode = HttpStatus.ok
+ ..content = kSolidBlueAvatar;
+
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Directionality(
+ textDirection: TextDirection.ltr,
+ child: GlobalStoreWidget(
+ child: PerAccountStoreWidget(
+ accountId: eg.selfAccount.id,
+ child: MessageContent(
+ message: message,
+ content: parseContent(html)))))));
+ await tester.pump(); // global store
+ await tester.pump(); // per-account store
+ debugNetworkImageHttpClientProvider = null;
+ }
+
+ testWidgets('single image', (tester) async {
+ const example = ContentExample.singleImage;
+ await prepareContent(tester, example.html);
+ final expectedImages = (example.expectedNodes[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('multiple images', (tester) async {
+ const example = ContentExample.multipleImages;
+ await prepareContent(tester, example.html);
+ final expectedImages = (example.expectedNodes[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));
+ });
+
+ testWidgets('content after image cluster', (tester) async {
+ const example = ContentExample.contentAfterImageCluster;
+ await prepareContent(tester, example.html);
+ final expectedImages = (example.expectedNodes[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));
+ });
+
+ testWidgets('multiple clusters of images', (tester) async {
+ const example = ContentExample.multipleImageClusters;
+ await prepareContent(tester, example.html);
+ final expectedImages = (example.expectedNodes[1] as ImageNodeList).images
+ + (example.expectedNodes[4] 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', () {
final authHeaders = authHeader(email: eg.selfAccount.email, apiKey: eg.selfAccount.apiKey);