Skip to content

Commit ac8fe80

Browse files
committed
content: Handle clusters of images in parseBlockContentList
1 parent b4e1a3e commit ac8fe80

File tree

4 files changed

+222
-14
lines changed

4 files changed

+222
-14
lines changed

lib/model/content.dart

+29-5
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,17 @@ class MathBlockNode extends BlockContentNode {
309309
}
310310
}
311311

312+
class ImageNodeList extends BlockContentNode {
313+
const ImageNodeList(this.images, {super.debugHtmlNode});
314+
315+
final List<ImageNode> images;
316+
317+
@override
318+
List<DiagnosticsNode> debugDescribeChildren() {
319+
return images.map((node) => node.toDiagnosticsNode()).toList();
320+
}
321+
}
322+
312323
class ImageNode extends BlockContentNode {
313324
const ImageNode({super.debugHtmlNode, required this.srcUrl});
314325

@@ -1031,13 +1042,26 @@ class _ZulipContentParser {
10311042

10321043
List<BlockContentNode> parseBlockContentList(dom.NodeList nodes) {
10331044
assert(_debugParserContext == _ParserContext.block);
1034-
final acceptedNodes = nodes.where((node) {
1045+
final List<BlockContentNode> result = [];
1046+
List<ImageNode> imageNodes = [];
1047+
for (final node in nodes) {
10351048
// We get a bunch of newline Text nodes between paragraphs.
10361049
// A browser seems to ignore these; let's do the same.
1037-
if (node is dom.Text && (node.text == '\n')) return false;
1038-
return true;
1039-
});
1040-
return acceptedNodes.map(parseBlockContent).toList(growable: false);
1050+
if (node is dom.Text && (node.text == '\n')) continue;
1051+
1052+
final block = parseBlockContent(node);
1053+
if (block is ImageNode) {
1054+
imageNodes.add(block);
1055+
continue;
1056+
}
1057+
if (imageNodes.isNotEmpty) {
1058+
result.add(ImageNodeList(imageNodes));
1059+
imageNodes = [];
1060+
}
1061+
result.add(block);
1062+
}
1063+
if (imageNodes.isNotEmpty) result.add(ImageNodeList(imageNodes));
1064+
return result;
10411065
}
10421066

10431067
ZulipContent parse(String html) {

lib/widgets/content.dart

+15-2
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ class BlockContentList extends StatelessWidget {
8484
return CodeBlock(node: node);
8585
} else if (node is MathBlockNode) {
8686
return MathBlock(node: node);
87+
} else if (node is ImageNodeList) {
88+
return MessageImageList(node: node);
8789
} else if (node is ImageNode) {
8890
return MessageImage(node: node);
8991
} else if (node is UnimplementedBlockContentNode) {
@@ -230,6 +232,18 @@ class ListItemWidget extends StatelessWidget {
230232
}
231233
}
232234

235+
class MessageImageList extends StatelessWidget {
236+
const MessageImageList({super.key, required this.node});
237+
238+
final ImageNodeList node;
239+
240+
@override
241+
Widget build(BuildContext context) {
242+
return Wrap(
243+
children: node.images.map((imageNode) => MessageImage(node: imageNode)).toList());
244+
}
245+
}
246+
233247
class MessageImage extends StatelessWidget {
234248
const MessageImage({super.key, required this.node});
235249

@@ -239,7 +253,6 @@ class MessageImage extends StatelessWidget {
239253
Widget build(BuildContext context) {
240254
final message = InheritedMessage.of(context);
241255

242-
// TODO(#193) multiple images in a row
243256
// TODO image hover animation
244257
final src = node.srcUrl;
245258

@@ -251,7 +264,7 @@ class MessageImage extends StatelessWidget {
251264
Navigator.of(context).push(getLightboxRoute(
252265
context: context, message: message, src: resolvedSrc));
253266
},
254-
child: Align(
267+
child: UnconstrainedBox(
255268
alignment: Alignment.centerLeft,
256269
child: Padding(
257270
// TODO clean up this padding by imitating web less precisely;

test/model/content_test.dart

+82-7
Original file line numberDiff line numberDiff line change
@@ -472,14 +472,89 @@ void main() {
472472
'<br>\n</p>\n</blockquote>',
473473
[const QuotationNode([MathBlockNode(texSource: r'\lambda')])]);
474474

475-
testParse('parse image',
476-
// "https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3"
477-
'<div class="message_inline_image">'
475+
group('Parsing images', () {
476+
testParse('single image',
477+
// "https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3"
478+
'<div class="message_inline_image">'
478479
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3">'
479-
'<img src="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3">'
480-
'</a></div>', const [
481-
ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3'),
482-
]);
480+
'<img src="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3"></a></div>',
481+
const [
482+
ImageNodeList([
483+
ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3'),
484+
]),
485+
]);
486+
487+
testParse('parse multiple images',
488+
// "https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3\nhttps://chat.zulip.org/user_avatars/2/realm/icon.png?version=4"
489+
'<p>'
490+
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3">https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3</a><br>\n'
491+
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=4">https://chat.zulip.org/user_avatars/2/realm/icon.png?version=4</a></p>\n'
492+
'<div class="message_inline_image">'
493+
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3">'
494+
'<img src="https://uploads.zulipusercontent.net/f535ba07f95b99a83aa48e44fd62bbb6c6cf6615/68747470733a2f2f636861742e7a756c69702e6f72672f757365725f617661746172732f322f7265616c6d2f69636f6e2e706e673f76657273696f6e3d33"></a></div>'
495+
'<div class="message_inline_image">'
496+
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=4">'
497+
'<img src="https://uploads.zulipusercontent.net/8f63bc2632a0e41be3f457d86c077e61b4a03e7e/68747470733a2f2f636861742e7a756c69702e6f72672f757365725f617661746172732f322f7265616c6d2f69636f6e2e706e673f76657273696f6e3d34"></a></div>',
498+
const [
499+
ParagraphNode(links: null, nodes: [
500+
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')]),
501+
LineBreakInlineNode(),
502+
TextNode('\n'),
503+
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')]),
504+
]),
505+
ImageNodeList([
506+
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/f535ba07f95b99a83aa48e44fd62bbb6c6cf6615/68747470733a2f2f636861742e7a756c69702e6f72672f757365725f617661746172732f322f7265616c6d2f69636f6e2e706e673f76657273696f6e3d33'),
507+
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/8f63bc2632a0e41be3f457d86c077e61b4a03e7e/68747470733a2f2f636861742e7a756c69702e6f72672f757365725f617661746172732f322f7265616c6d2f69636f6e2e706e673f76657273696f6e3d34'),
508+
]),
509+
]);
510+
511+
testParse('multiple clusters of images',
512+
// "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"
513+
'<p>'
514+
'<a href="https://en.wikipedia.org/static/images/icons/wikipedia.png">https://en.wikipedia.org/static/images/icons/wikipedia.png</a><br>\n'
515+
'<a href="https://en.wikipedia.org/static/images/icons/wikipedia.png?v=1">https://en.wikipedia.org/static/images/icons/wikipedia.png?v=1</a></p>\n'
516+
'<div class="message_inline_image">'
517+
'<a href="https://en.wikipedia.org/static/images/icons/wikipedia.png">'
518+
'<img src="https://uploads.zulipusercontent.net/34b2695ca83af76204b0b25a8f2019ee35ec38fa/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e67"></a></div>'
519+
'<div class="message_inline_image">'
520+
'<a href="https://en.wikipedia.org/static/images/icons/wikipedia.png?v=1">'
521+
'<img src="https://uploads.zulipusercontent.net/d200fb112aaccbff9df767373a201fa59601f362/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d31"></a></div>'
522+
'<p>Test</p>\n'
523+
'<p>'
524+
'<a href="https://en.wikipedia.org/static/images/icons/wikipedia.png?v=2">https://en.wikipedia.org/static/images/icons/wikipedia.png?v=2</a><br>\n'
525+
'<a href="https://en.wikipedia.org/static/images/icons/wikipedia.png?v=3">https://en.wikipedia.org/static/images/icons/wikipedia.png?v=3</a></p>\n'
526+
'<div class="message_inline_image">'
527+
'<a href="https://en.wikipedia.org/static/images/icons/wikipedia.png?v=2">'
528+
'<img src="https://uploads.zulipusercontent.net/c4db87e81348dac94eacaa966b46d968b34029cc/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d32"></a></div>'
529+
'<div class="message_inline_image">'
530+
'<a href="https://en.wikipedia.org/static/images/icons/wikipedia.png?v=3">'
531+
'<img src="https://uploads.zulipusercontent.net/51b70540cf6a5b3c8a0b919c893b8abddd447e88/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d33"></a></div>',
532+
const [
533+
ParagraphNode(links: null, nodes: [
534+
LinkNode(url: 'https://en.wikipedia.org/static/images/icons/wikipedia.png', nodes: [TextNode('https://en.wikipedia.org/static/images/icons/wikipedia.png')]),
535+
LineBreakInlineNode(),
536+
TextNode('\n'),
537+
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')]),
538+
]),
539+
ImageNodeList([
540+
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/34b2695ca83af76204b0b25a8f2019ee35ec38fa/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e67'),
541+
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/d200fb112aaccbff9df767373a201fa59601f362/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d31'),
542+
]),
543+
ParagraphNode(links: null, nodes: [
544+
TextNode('Test'),
545+
]),
546+
ParagraphNode(links: null, nodes: [
547+
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')]),
548+
LineBreakInlineNode(),
549+
TextNode('\n'),
550+
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')]),
551+
]),
552+
ImageNodeList([
553+
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/c4db87e81348dac94eacaa966b46d968b34029cc/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d32'),
554+
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/51b70540cf6a5b3c8a0b919c893b8abddd447e88/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d33'),
555+
]),
556+
]);
557+
});
483558

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

test/widgets/content_test.dart

+96
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,102 @@ void main() {
294294
tester.widget(find.textContaining(RegExp(r'^(Tue, Jan 30|Wed, Jan 31), 2024, \d+:\d\d [AP]M$')));
295295
});
296296

297+
group('MessageImages', () {
298+
final message = eg.streamMessage();
299+
300+
Future<void> prepareContent(WidgetTester tester, String html) async {
301+
addTearDown(testBinding.reset);
302+
303+
await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot());
304+
final httpClient = FakeImageHttpClient();
305+
306+
debugNetworkImageHttpClientProvider = () => httpClient;
307+
httpClient.request.response
308+
..statusCode = HttpStatus.ok
309+
..content = kSolidBlueAvatar;
310+
311+
await tester.pumpWidget(
312+
MaterialApp(
313+
home: Directionality(
314+
textDirection: TextDirection.ltr,
315+
child: GlobalStoreWidget(
316+
child: PerAccountStoreWidget(
317+
accountId: eg.selfAccount.id,
318+
child: MessageContent(
319+
message: message,
320+
content: parseContent(html)))))));
321+
await tester.pump(); // global store
322+
await tester.pump(); // per-account store
323+
debugNetworkImageHttpClientProvider = null;
324+
}
325+
326+
testWidgets('single image', (tester) async {
327+
// "https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3"
328+
await prepareContent(tester,
329+
'<div class="message_inline_image">'
330+
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3">'
331+
'<img src="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3"></a></div>');
332+
tester.widget(find.byType(RealmContentNetworkImage));
333+
final images = tester.widgetList<RealmContentNetworkImage>(find.byType(RealmContentNetworkImage));
334+
check(images.map((i) => i.src.toString()).toList())
335+
.deepEquals([
336+
'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3'
337+
]);
338+
});
339+
340+
testWidgets('parse multiple images', (tester) async {
341+
// "https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3\nhttps://chat.zulip.org/user_avatars/2/realm/icon.png?version=4"
342+
await prepareContent(tester,
343+
'<p>'
344+
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3">https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3</a><br>\n'
345+
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=4">https://chat.zulip.org/user_avatars/2/realm/icon.png?version=4</a></p>\n'
346+
'<div class="message_inline_image">'
347+
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3">'
348+
'<img src="https://uploads.zulipusercontent.net/f535ba07f95b99a83aa48e44fd62bbb6c6cf6615/68747470733a2f2f636861742e7a756c69702e6f72672f757365725f617661746172732f322f7265616c6d2f69636f6e2e706e673f76657273696f6e3d33"></a></div>'
349+
'<div class="message_inline_image">'
350+
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=4">'
351+
'<img src="https://uploads.zulipusercontent.net/8f63bc2632a0e41be3f457d86c077e61b4a03e7e/68747470733a2f2f636861742e7a756c69702e6f72672f757365725f617661746172732f322f7265616c6d2f69636f6e2e706e673f76657273696f6e3d34"></a></div>');
352+
final images = tester.widgetList<RealmContentNetworkImage>(find.byType(RealmContentNetworkImage));
353+
check(images.map((i) => i.src.toString()).toList())
354+
.deepEquals([
355+
'https://uploads.zulipusercontent.net/f535ba07f95b99a83aa48e44fd62bbb6c6cf6615/68747470733a2f2f636861742e7a756c69702e6f72672f757365725f617661746172732f322f7265616c6d2f69636f6e2e706e673f76657273696f6e3d33',
356+
'https://uploads.zulipusercontent.net/8f63bc2632a0e41be3f457d86c077e61b4a03e7e/68747470733a2f2f636861742e7a756c69702e6f72672f757365725f617661746172732f322f7265616c6d2f69636f6e2e706e673f76657273696f6e3d34',
357+
]);
358+
});
359+
360+
testWidgets('multiple clusters of images', (tester) async {
361+
// "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"
362+
await prepareContent(tester,
363+
'<p>'
364+
'<a href="https://en.wikipedia.org/static/images/icons/wikipedia.png">https://en.wikipedia.org/static/images/icons/wikipedia.png</a><br>\n'
365+
'<a href="https://en.wikipedia.org/static/images/icons/wikipedia.png?v=1">https://en.wikipedia.org/static/images/icons/wikipedia.png?v=1</a></p>\n'
366+
'<div class="message_inline_image">'
367+
'<a href="https://en.wikipedia.org/static/images/icons/wikipedia.png">'
368+
'<img src="https://uploads.zulipusercontent.net/34b2695ca83af76204b0b25a8f2019ee35ec38fa/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e67"></a></div>'
369+
'<div class="message_inline_image">'
370+
'<a href="https://en.wikipedia.org/static/images/icons/wikipedia.png?v=1">'
371+
'<img src="https://uploads.zulipusercontent.net/d200fb112aaccbff9df767373a201fa59601f362/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d31"></a></div>'
372+
'<p>Test</p>\n'
373+
'<p>'
374+
'<a href="https://en.wikipedia.org/static/images/icons/wikipedia.png?v=2">https://en.wikipedia.org/static/images/icons/wikipedia.png?v=2</a><br>\n'
375+
'<a href="https://en.wikipedia.org/static/images/icons/wikipedia.png?v=3">https://en.wikipedia.org/static/images/icons/wikipedia.png?v=3</a></p>\n'
376+
'<div class="message_inline_image">'
377+
'<a href="https://en.wikipedia.org/static/images/icons/wikipedia.png?v=2">'
378+
'<img src="https://uploads.zulipusercontent.net/c4db87e81348dac94eacaa966b46d968b34029cc/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d32"></a></div>'
379+
'<div class="message_inline_image">'
380+
'<a href="https://en.wikipedia.org/static/images/icons/wikipedia.png?v=3">'
381+
'<img src="https://uploads.zulipusercontent.net/51b70540cf6a5b3c8a0b919c893b8abddd447e88/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d33"></a></div>');
382+
final images = tester.widgetList<RealmContentNetworkImage>(find.byType(RealmContentNetworkImage));
383+
check(images.map((i) => i.src.toString()).toList())
384+
.deepEquals([
385+
'https://uploads.zulipusercontent.net/34b2695ca83af76204b0b25a8f2019ee35ec38fa/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e67',
386+
'https://uploads.zulipusercontent.net/d200fb112aaccbff9df767373a201fa59601f362/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d31',
387+
'https://uploads.zulipusercontent.net/c4db87e81348dac94eacaa966b46d968b34029cc/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d32',
388+
'https://uploads.zulipusercontent.net/51b70540cf6a5b3c8a0b919c893b8abddd447e88/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d33',
389+
]);
390+
});
391+
});
392+
297393
group('RealmContentNetworkImage', () {
298394
final authHeaders = authHeader(email: eg.selfAccount.email, apiKey: eg.selfAccount.apiKey);
299395

0 commit comments

Comments
 (0)