Skip to content

Commit 219c1f8

Browse files
committed
content: Handle clusters of images in parseBlockContentList
1 parent c5d5eed commit 219c1f8

File tree

4 files changed

+224
-15
lines changed

4 files changed

+224
-15
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

@@ -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

Lines changed: 15 additions & 2 deletions
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

Lines changed: 113 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,115 @@ class ContentExample {
248248
'<span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.6944em;"></span><span class="mord mathnormal">λ</span></span></span></span></span>'
249249
'<br>\n</p>\n</blockquote>',
250250
[QuotationNode([MathBlockNode(texSource: r'\lambda')])]);
251+
252+
static const singleImage = ContentExample(
253+
'single image',
254+
"https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3",
255+
'<div class="message_inline_image">'
256+
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3">'
257+
'<img src="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3"></a></div>', [
258+
ImageNodeList([
259+
ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3'),
260+
]),
261+
]);
262+
263+
static const multipleImages = ContentExample(
264+
'multiple images',
265+
"https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3\nhttps://chat.zulip.org/user_avatars/2/realm/icon.png?version=4",
266+
'<p>'
267+
'<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'
268+
'<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'
269+
'<div class="message_inline_image">'
270+
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3">'
271+
'<img src="https://uploads.zulipusercontent.net/f535ba07f95b99a83aa48e44fd62bbb6c6cf6615/68747470733a2f2f636861742e7a756c69702e6f72672f757365725f617661746172732f322f7265616c6d2f69636f6e2e706e673f76657273696f6e3d33"></a></div>'
272+
'<div class="message_inline_image">'
273+
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=4">'
274+
'<img src="https://uploads.zulipusercontent.net/8f63bc2632a0e41be3f457d86c077e61b4a03e7e/68747470733a2f2f636861742e7a756c69702e6f72672f757365725f617661746172732f322f7265616c6d2f69636f6e2e706e673f76657273696f6e3d34"></a></div>', [
275+
ParagraphNode(links: null, nodes: [
276+
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')]),
277+
LineBreakInlineNode(),
278+
TextNode('\n'),
279+
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')]),
280+
]),
281+
ImageNodeList([
282+
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/f535ba07f95b99a83aa48e44fd62bbb6c6cf6615/68747470733a2f2f636861742e7a756c69702e6f72672f757365725f617661746172732f322f7265616c6d2f69636f6e2e706e673f76657273696f6e3d33'),
283+
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/8f63bc2632a0e41be3f457d86c077e61b4a03e7e/68747470733a2f2f636861742e7a756c69702e6f72672f757365725f617661746172732f322f7265616c6d2f69636f6e2e706e673f76657273696f6e3d34'),
284+
]),
285+
]);
286+
287+
static const contentAfterImageCluster = ContentExample(
288+
'content after image cluster',
289+
"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",
290+
'<p>content '
291+
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png">icon.png</a> '
292+
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=2">icon.png</a></p>\n'
293+
'<div class="message_inline_image">'
294+
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png" title="icon.png">'
295+
'<img src="https://chat.zulip.org/user_avatars/2/realm/icon.png"></a></div>'
296+
'<div class="message_inline_image">'
297+
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=2" title="icon.png">'
298+
'<img src="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=2"></a></div>'
299+
'<p>more content</p>', [
300+
ParagraphNode(links: null, nodes: [
301+
TextNode('content '),
302+
LinkNode(url: 'https://chat.zulip.org/user_avatars/2/realm/icon.png', nodes: [TextNode('icon.png')]),
303+
TextNode(' '),
304+
LinkNode(url: 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=2', nodes: [TextNode('icon.png')]),
305+
]),
306+
ImageNodeList([
307+
ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png'),
308+
ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=2'),
309+
]),
310+
ParagraphNode(links: null, nodes: [
311+
TextNode('more content'),
312+
]),
313+
]);
314+
315+
static const multipleImageClusters = ContentExample(
316+
'multiple clusters of images',
317+
"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",
318+
'<p>'
319+
'<a href="https://en.wikipedia.org/static/images/icons/wikipedia.png">https://en.wikipedia.org/static/images/icons/wikipedia.png</a><br>\n' '<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'
320+
'<div class="message_inline_image">'
321+
'<a href="https://en.wikipedia.org/static/images/icons/wikipedia.png">'
322+
'<img src="https://uploads.zulipusercontent.net/34b2695ca83af76204b0b25a8f2019ee35ec38fa/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e67"></a></div>'
323+
'<div class="message_inline_image">'
324+
'<a href="https://en.wikipedia.org/static/images/icons/wikipedia.png?v=1">'
325+
'<img src="https://uploads.zulipusercontent.net/d200fb112aaccbff9df767373a201fa59601f362/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d31"></a></div>'
326+
'<p>Test</p>\n'
327+
'<p>'
328+
'<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'
329+
'<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'
330+
'<div class="message_inline_image">'
331+
'<a href="https://en.wikipedia.org/static/images/icons/wikipedia.png?v=2">'
332+
'<img src="https://uploads.zulipusercontent.net/c4db87e81348dac94eacaa966b46d968b34029cc/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d32"></a></div>'
333+
'<div class="message_inline_image">'
334+
'<a href="https://en.wikipedia.org/static/images/icons/wikipedia.png?v=3">'
335+
'<img src="https://uploads.zulipusercontent.net/51b70540cf6a5b3c8a0b919c893b8abddd447e88/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d33"></a></div>', [
336+
ParagraphNode(links: null, nodes: [
337+
LinkNode(url: 'https://en.wikipedia.org/static/images/icons/wikipedia.png', nodes: [TextNode('https://en.wikipedia.org/static/images/icons/wikipedia.png')]),
338+
LineBreakInlineNode(),
339+
TextNode('\n'),
340+
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')]),
341+
]),
342+
ImageNodeList([
343+
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/34b2695ca83af76204b0b25a8f2019ee35ec38fa/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e67'),
344+
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/d200fb112aaccbff9df767373a201fa59601f362/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d31'),
345+
]),
346+
ParagraphNode(links: null, nodes: [
347+
TextNode('Test'),
348+
]),
349+
ParagraphNode(links: null, nodes: [
350+
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')]),
351+
LineBreakInlineNode(),
352+
TextNode('\n'),
353+
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')]),
354+
]),
355+
ImageNodeList([
356+
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/c4db87e81348dac94eacaa966b46d968b34029cc/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d32'),
357+
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/51b70540cf6a5b3c8a0b919c893b8abddd447e88/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d33'),
358+
]),
359+
]);
251360
}
252361

253362
UnimplementedBlockContentNode blockUnimplemented(String html) {
@@ -572,14 +681,10 @@ void main() {
572681
testParseExample(ContentExample.mathBlock);
573682
testParseExample(ContentExample.mathBlockInQuote);
574683

575-
testParse('parse image',
576-
// "https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3"
577-
'<div class="message_inline_image">'
578-
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3">'
579-
'<img src="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3">'
580-
'</a></div>', const [
581-
ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3'),
582-
]);
684+
testParseExample(ContentExample.singleImage);
685+
testParseExample(ContentExample.multipleImages);
686+
testParseExample(ContentExample.contentAfterImageCluster);
687+
testParseExample(ContentExample.multipleImageClusters);
583688

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

test/widgets/content_test.dart

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,73 @@ void main() {
250250
tester.widget(find.textContaining(RegExp(r'^(Tue, Jan 30|Wed, Jan 31), 2024, \d+:\d\d [AP]M$')));
251251
});
252252

253+
group('MessageImages', () {
254+
final message = eg.streamMessage();
255+
256+
Future<void> prepareContent(WidgetTester tester, String html) async {
257+
addTearDown(testBinding.reset);
258+
259+
await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot());
260+
final httpClient = FakeImageHttpClient();
261+
262+
debugNetworkImageHttpClientProvider = () => httpClient;
263+
httpClient.request.response
264+
..statusCode = HttpStatus.ok
265+
..content = kSolidBlueAvatar;
266+
267+
await tester.pumpWidget(
268+
MaterialApp(
269+
home: Directionality(
270+
textDirection: TextDirection.ltr,
271+
child: GlobalStoreWidget(
272+
child: PerAccountStoreWidget(
273+
accountId: eg.selfAccount.id,
274+
child: MessageContent(
275+
message: message,
276+
content: parseContent(html)))))));
277+
await tester.pump(); // global store
278+
await tester.pump(); // per-account store
279+
debugNetworkImageHttpClientProvider = null;
280+
}
281+
282+
testWidgets('single image', (tester) async {
283+
const example = ContentExample.singleImage;
284+
await prepareContent(tester, example.html);
285+
final expectedImages = (example.expectedNodes[0] as ImageNodeList).images;
286+
final images = tester.widgetList<RealmContentNetworkImage>(find.byType(RealmContentNetworkImage));
287+
check(images.map((i) => i.src.toString()).toList())
288+
.deepEquals(expectedImages.map((n) => n.srcUrl));
289+
});
290+
291+
testWidgets('multiple images', (tester) async {
292+
const example = ContentExample.multipleImages;
293+
await prepareContent(tester, example.html);
294+
final expectedImages = (example.expectedNodes[1] as ImageNodeList).images;
295+
final images = tester.widgetList<RealmContentNetworkImage>(find.byType(RealmContentNetworkImage));
296+
check(images.map((i) => i.src.toString()).toList())
297+
.deepEquals(expectedImages.map((n) => n.srcUrl));
298+
});
299+
300+
testWidgets('content after image cluster', (tester) async {
301+
const example = ContentExample.contentAfterImageCluster;
302+
await prepareContent(tester, example.html);
303+
final expectedImages = (example.expectedNodes[1] as ImageNodeList).images;
304+
final images = tester.widgetList<RealmContentNetworkImage>(find.byType(RealmContentNetworkImage));
305+
check(images.map((i) => i.src.toString()).toList())
306+
.deepEquals(expectedImages.map((n) => n.srcUrl));
307+
});
308+
309+
testWidgets('multiple clusters of images', (tester) async {
310+
const example = ContentExample.multipleImageClusters;
311+
await prepareContent(tester, example.html);
312+
final expectedImages = (example.expectedNodes[1] as ImageNodeList).images
313+
+ (example.expectedNodes[4] as ImageNodeList).images;
314+
final images = tester.widgetList<RealmContentNetworkImage>(find.byType(RealmContentNetworkImage));
315+
check(images.map((i) => i.src.toString()).toList())
316+
.deepEquals(expectedImages.map((n) => n.srcUrl));
317+
});
318+
});
319+
253320
group('RealmContentNetworkImage', () {
254321
final authHeaders = authHeader(email: eg.selfAccount.email, apiKey: eg.selfAccount.apiKey);
255322

0 commit comments

Comments
 (0)