Skip to content

Commit 1a0128a

Browse files
committed
content: Handle clusters of images in parseBlockContentList
1 parent e3894cd commit 1a0128a

File tree

4 files changed

+222
-14
lines changed

4 files changed

+222
-14
lines changed

lib/model/content.dart

Lines changed: 29 additions & 5 deletions
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

@@ -1012,13 +1023,26 @@ class _ZulipContentParser {
10121023

10131024
List<BlockContentNode> parseBlockContentList(dom.NodeList nodes) {
10141025
assert(_debugParserContext == _ParserContext.block);
1015-
final acceptedNodes = nodes.where((node) {
1026+
final List<BlockContentNode> blocks = [];
1027+
List<ImageNode> imageNodes = [];
1028+
for (final node in nodes) {
10161029
// We get a bunch of newline Text nodes between paragraphs.
10171030
// A browser seems to ignore these; let's do the same.
1018-
if (node is dom.Text && (node.text == '\n')) return false;
1019-
return true;
1020-
});
1021-
return acceptedNodes.map(parseBlockContent).toList(growable: false);
1031+
if (node is dom.Text && (node.text == '\n')) continue;
1032+
1033+
final block = parseBlockContent(node);
1034+
if (block is ImageNode) {
1035+
imageNodes.add(block);
1036+
continue;
1037+
}
1038+
if (imageNodes.isNotEmpty) {
1039+
blocks.add(ImageNodeList(imageNodes));
1040+
imageNodes = [];
1041+
}
1042+
blocks.add(block);
1043+
}
1044+
if (imageNodes.isNotEmpty) blocks.add(ImageNodeList(imageNodes));
1045+
return blocks;
10221046
}
10231047

10241048
ZulipContent parse(String html) {

lib/widgets/content.dart

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@ class BlockContentList extends StatelessWidget {
8282
return CodeBlock(node: node);
8383
} else if (node is MathBlockNode) {
8484
return MathBlock(node: node);
85+
} else if (node is ImageNodeList) {
86+
return MessageImageList(node: node);
8587
} else if (node is ImageNode) {
8688
return MessageImage(node: node);
8789
} else if (node is UnimplementedBlockContentNode) {
@@ -228,6 +230,18 @@ class ListItemWidget extends StatelessWidget {
228230
}
229231
}
230232

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

@@ -237,7 +251,6 @@ class MessageImage extends StatelessWidget {
237251
Widget build(BuildContext context) {
238252
final message = InheritedMessage.of(context);
239253

240-
// TODO(#193) multiple images in a row
241254
// TODO image hover animation
242255
final src = node.srcUrl;
243256

@@ -249,7 +262,7 @@ class MessageImage extends StatelessWidget {
249262
Navigator.of(context).push(getLightboxRoute(
250263
context: context, message: message, src: resolvedSrc));
251264
},
252-
child: Align(
265+
child: UnconstrainedBox(
253266
alignment: Alignment.centerLeft,
254267
child: Padding(
255268
// TODO clean up this padding by imitating web less precisely;

test/model/content_test.dart

Lines changed: 82 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -421,14 +421,89 @@ void main() {
421421
'<br>\n</p>\n</blockquote>',
422422
[const QuotationNode([MathBlockNode(texSource: r'\lambda')])]);
423423

424-
testParse('parse image',
425-
// "https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3"
426-
'<div class="message_inline_image">'
424+
group('Parsing images', () {
425+
testParse('single image',
426+
// "https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3"
427+
'<div class="message_inline_image">'
427428
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3">'
428-
'<img src="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3">'
429-
'</a></div>', const [
430-
ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3'),
431-
]);
429+
'<img src="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3"></a></div>',
430+
const [
431+
ImageNodeList([
432+
ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3'),
433+
]),
434+
]);
435+
436+
testParse('parse multiple images',
437+
// "https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3\nhttps://chat.zulip.org/user_avatars/2/realm/icon.png?version=4"
438+
'<p>'
439+
'<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'
440+
'<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'
441+
'<div class="message_inline_image">'
442+
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3">'
443+
'<img src="https://uploads.zulipusercontent.net/f535ba07f95b99a83aa48e44fd62bbb6c6cf6615/68747470733a2f2f636861742e7a756c69702e6f72672f757365725f617661746172732f322f7265616c6d2f69636f6e2e706e673f76657273696f6e3d33"></a></div>'
444+
'<div class="message_inline_image">'
445+
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=4">'
446+
'<img src="https://uploads.zulipusercontent.net/8f63bc2632a0e41be3f457d86c077e61b4a03e7e/68747470733a2f2f636861742e7a756c69702e6f72672f757365725f617661746172732f322f7265616c6d2f69636f6e2e706e673f76657273696f6e3d34"></a></div>',
447+
const [
448+
ParagraphNode(links: null, nodes: [
449+
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')]),
450+
LineBreakInlineNode(),
451+
TextNode('\n'),
452+
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')]),
453+
]),
454+
ImageNodeList([
455+
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/f535ba07f95b99a83aa48e44fd62bbb6c6cf6615/68747470733a2f2f636861742e7a756c69702e6f72672f757365725f617661746172732f322f7265616c6d2f69636f6e2e706e673f76657273696f6e3d33'),
456+
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/8f63bc2632a0e41be3f457d86c077e61b4a03e7e/68747470733a2f2f636861742e7a756c69702e6f72672f757365725f617661746172732f322f7265616c6d2f69636f6e2e706e673f76657273696f6e3d34'),
457+
]),
458+
]);
459+
460+
testParse('multiple clusters of images',
461+
// "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"
462+
'<p>'
463+
'<a href="https://en.wikipedia.org/static/images/icons/wikipedia.png">https://en.wikipedia.org/static/images/icons/wikipedia.png</a><br>\n'
464+
'<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'
465+
'<div class="message_inline_image">'
466+
'<a href="https://en.wikipedia.org/static/images/icons/wikipedia.png">'
467+
'<img src="https://uploads.zulipusercontent.net/34b2695ca83af76204b0b25a8f2019ee35ec38fa/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e67"></a></div>'
468+
'<div class="message_inline_image">'
469+
'<a href="https://en.wikipedia.org/static/images/icons/wikipedia.png?v=1">'
470+
'<img src="https://uploads.zulipusercontent.net/d200fb112aaccbff9df767373a201fa59601f362/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d31"></a></div>'
471+
'<p>Test</p>\n'
472+
'<p>'
473+
'<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'
474+
'<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'
475+
'<div class="message_inline_image">'
476+
'<a href="https://en.wikipedia.org/static/images/icons/wikipedia.png?v=2">'
477+
'<img src="https://uploads.zulipusercontent.net/c4db87e81348dac94eacaa966b46d968b34029cc/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d32"></a></div>'
478+
'<div class="message_inline_image">'
479+
'<a href="https://en.wikipedia.org/static/images/icons/wikipedia.png?v=3">'
480+
'<img src="https://uploads.zulipusercontent.net/51b70540cf6a5b3c8a0b919c893b8abddd447e88/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d33"></a></div>',
481+
const [
482+
ParagraphNode(links: null, nodes: [
483+
LinkNode(url: 'https://en.wikipedia.org/static/images/icons/wikipedia.png', nodes: [TextNode('https://en.wikipedia.org/static/images/icons/wikipedia.png')]),
484+
LineBreakInlineNode(),
485+
TextNode('\n'),
486+
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')]),
487+
]),
488+
ImageNodeList([
489+
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/34b2695ca83af76204b0b25a8f2019ee35ec38fa/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e67'),
490+
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/d200fb112aaccbff9df767373a201fa59601f362/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d31'),
491+
]),
492+
ParagraphNode(links: null, nodes: [
493+
TextNode('Test'),
494+
]),
495+
ParagraphNode(links: null, nodes: [
496+
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')]),
497+
LineBreakInlineNode(),
498+
TextNode('\n'),
499+
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')]),
500+
]),
501+
ImageNodeList([
502+
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/c4db87e81348dac94eacaa966b46d968b34029cc/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d32'),
503+
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/51b70540cf6a5b3c8a0b919c893b8abddd447e88/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d33'),
504+
]),
505+
]);
506+
});
432507

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

test/widgets/content_test.dart

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,102 @@ void main() {
282282
tester.widget(find.text(r'\lambda'));
283283
});
284284

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

0 commit comments

Comments
 (0)