Skip to content

Commit 90fbcf8

Browse files
committed
content: Combine clusters of images in messages
Fixes: #193
1 parent 5cee06b commit 90fbcf8

File tree

4 files changed

+221
-15
lines changed

4 files changed

+221
-15
lines changed

lib/model/content.dart

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,17 @@ class MathBlockNode extends BlockContentNode {
309309
}
310310
}
311311

312+
class ImageNodes extends BlockContentNode {
313+
const ImageNodes(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

@@ -1013,13 +1024,24 @@ class _ZulipContentParser {
10131024

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

10251047
ZulipContent parse(String html) {

lib/widgets/content.dart

Lines changed: 14 additions & 1 deletion
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 ImageNodes) {
86+
return MessageImages(node: node);
8587
} else if (node is ImageNode) {
8688
return MessageImage(node: node);
8789
} else if (node is UnimplementedBlockContentNode) {
@@ -219,6 +221,18 @@ class ListItemWidget extends StatelessWidget {
219221
}
220222
}
221223

224+
class MessageImages extends StatelessWidget {
225+
const MessageImages({super.key, required this.node});
226+
227+
final ImageNodes node;
228+
229+
@override
230+
Widget build(BuildContext context) {
231+
return Wrap(
232+
children: node.images.map((imageNode) => MessageImage(node: imageNode)).toList());
233+
}
234+
}
235+
222236
class MessageImage extends StatelessWidget {
223237
const MessageImage({super.key, required this.node});
224238

@@ -228,7 +242,6 @@ class MessageImage extends StatelessWidget {
228242
Widget build(BuildContext context) {
229243
final message = InheritedMessage.of(context);
230244

231-
// TODO(#193) multiple images in a row
232245
// TODO image hover animation
233246
final src = node.srcUrl;
234247

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+
ImageNodes([
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+
ImageNodes([
455+
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/f535ba07f95b99a83aa48e44fd62bbb6c6cf6615/68747470733a2f2f636861742e7a756c69702e6f72672f757365725f617661746172732f322f7265616c6d2f69636f6e2e706e673f76657273696f6e3d33'),
456+
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/8f63bc2632a0e41be3f457d86c077e61b4a03e7e/68747470733a2f2f636861742e7a756c69702e6f72672f757365725f617661746172732f322f7265616c6d2f69636f6e2e706e673f76657273696f6e3d34'),
457+
]),
458+
]);
459+
460+
testParse('multiple cluster 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+
ImageNodes([
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+
ImageNodes([
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
@@ -262,6 +262,102 @@ void main() {
262262
tester.widget(find.text(r'\lambda'));
263263
});
264264

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

0 commit comments

Comments
 (0)