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);