Skip to content

Commit 72efa3e

Browse files
sirpengignprice
authored andcommitted
content: Handle clusters of images in parseBlockContentList
1 parent da68a51 commit 72efa3e

File tree

4 files changed

+228
-15
lines changed

4 files changed

+228
-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
@@ -256,6 +256,115 @@ class ContentExample {
256256
'<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>'
257257
'<br>\n</p>\n</blockquote>',
258258
[QuotationNode([MathBlockNode(texSource: r'\lambda')])]);
259+
260+
static const imageSingle = ContentExample(
261+
'single image',
262+
"https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3",
263+
'<div class="message_inline_image">'
264+
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3">'
265+
'<img src="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3"></a></div>', [
266+
ImageNodeList([
267+
ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3'),
268+
]),
269+
]);
270+
271+
static const imageCluster = ContentExample(
272+
'multiple images',
273+
"https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3\nhttps://chat.zulip.org/user_avatars/2/realm/icon.png?version=4",
274+
'<p>'
275+
'<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'
276+
'<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'
277+
'<div class="message_inline_image">'
278+
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3">'
279+
'<img src="https://uploads.zulipusercontent.net/f535ba07f95b99a83aa48e44fd62bbb6c6cf6615/68747470733a2f2f636861742e7a756c69702e6f72672f757365725f617661746172732f322f7265616c6d2f69636f6e2e706e673f76657273696f6e3d33"></a></div>'
280+
'<div class="message_inline_image">'
281+
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=4">'
282+
'<img src="https://uploads.zulipusercontent.net/8f63bc2632a0e41be3f457d86c077e61b4a03e7e/68747470733a2f2f636861742e7a756c69702e6f72672f757365725f617661746172732f322f7265616c6d2f69636f6e2e706e673f76657273696f6e3d34"></a></div>', [
283+
ParagraphNode(links: null, nodes: [
284+
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')]),
285+
LineBreakInlineNode(),
286+
TextNode('\n'),
287+
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')]),
288+
]),
289+
ImageNodeList([
290+
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/f535ba07f95b99a83aa48e44fd62bbb6c6cf6615/68747470733a2f2f636861742e7a756c69702e6f72672f757365725f617661746172732f322f7265616c6d2f69636f6e2e706e673f76657273696f6e3d33'),
291+
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/8f63bc2632a0e41be3f457d86c077e61b4a03e7e/68747470733a2f2f636861742e7a756c69702e6f72672f757365725f617661746172732f322f7265616c6d2f69636f6e2e706e673f76657273696f6e3d34'),
292+
]),
293+
]);
294+
295+
static const imageClusterThenContent = ContentExample(
296+
'content after image cluster',
297+
"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",
298+
'<p>content '
299+
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png">icon.png</a> '
300+
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=2">icon.png</a></p>\n'
301+
'<div class="message_inline_image">'
302+
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png" title="icon.png">'
303+
'<img src="https://chat.zulip.org/user_avatars/2/realm/icon.png"></a></div>'
304+
'<div class="message_inline_image">'
305+
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=2" title="icon.png">'
306+
'<img src="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=2"></a></div>'
307+
'<p>more content</p>', [
308+
ParagraphNode(links: null, nodes: [
309+
TextNode('content '),
310+
LinkNode(url: 'https://chat.zulip.org/user_avatars/2/realm/icon.png', nodes: [TextNode('icon.png')]),
311+
TextNode(' '),
312+
LinkNode(url: 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=2', nodes: [TextNode('icon.png')]),
313+
]),
314+
ImageNodeList([
315+
ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png'),
316+
ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=2'),
317+
]),
318+
ParagraphNode(links: null, nodes: [
319+
TextNode('more content'),
320+
]),
321+
]);
322+
323+
static const imageMultipleClusters = ContentExample(
324+
'multiple clusters of images',
325+
"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",
326+
'<p>'
327+
'<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'
328+
'<div class="message_inline_image">'
329+
'<a href="https://en.wikipedia.org/static/images/icons/wikipedia.png">'
330+
'<img src="https://uploads.zulipusercontent.net/34b2695ca83af76204b0b25a8f2019ee35ec38fa/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e67"></a></div>'
331+
'<div class="message_inline_image">'
332+
'<a href="https://en.wikipedia.org/static/images/icons/wikipedia.png?v=1">'
333+
'<img src="https://uploads.zulipusercontent.net/d200fb112aaccbff9df767373a201fa59601f362/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d31"></a></div>'
334+
'<p>Test</p>\n'
335+
'<p>'
336+
'<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'
337+
'<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'
338+
'<div class="message_inline_image">'
339+
'<a href="https://en.wikipedia.org/static/images/icons/wikipedia.png?v=2">'
340+
'<img src="https://uploads.zulipusercontent.net/c4db87e81348dac94eacaa966b46d968b34029cc/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d32"></a></div>'
341+
'<div class="message_inline_image">'
342+
'<a href="https://en.wikipedia.org/static/images/icons/wikipedia.png?v=3">'
343+
'<img src="https://uploads.zulipusercontent.net/51b70540cf6a5b3c8a0b919c893b8abddd447e88/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d33"></a></div>', [
344+
ParagraphNode(links: null, nodes: [
345+
LinkNode(url: 'https://en.wikipedia.org/static/images/icons/wikipedia.png', nodes: [TextNode('https://en.wikipedia.org/static/images/icons/wikipedia.png')]),
346+
LineBreakInlineNode(),
347+
TextNode('\n'),
348+
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')]),
349+
]),
350+
ImageNodeList([
351+
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/34b2695ca83af76204b0b25a8f2019ee35ec38fa/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e67'),
352+
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/d200fb112aaccbff9df767373a201fa59601f362/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d31'),
353+
]),
354+
ParagraphNode(links: null, nodes: [
355+
TextNode('Test'),
356+
]),
357+
ParagraphNode(links: null, nodes: [
358+
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')]),
359+
LineBreakInlineNode(),
360+
TextNode('\n'),
361+
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')]),
362+
]),
363+
ImageNodeList([
364+
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/c4db87e81348dac94eacaa966b46d968b34029cc/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d32'),
365+
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/51b70540cf6a5b3c8a0b919c893b8abddd447e88/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d33'),
366+
]),
367+
]);
259368
}
260369

261370
UnimplementedBlockContentNode blockUnimplemented(String html) {
@@ -576,14 +685,10 @@ void main() {
576685
testParseExample(ContentExample.mathBlock);
577686
testParseExample(ContentExample.mathBlockInQuote);
578687

579-
testParse('parse image',
580-
// "https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3"
581-
'<div class="message_inline_image">'
582-
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3">'
583-
'<img src="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3">'
584-
'</a></div>', const [
585-
ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3'),
586-
]);
688+
testParseExample(ContentExample.imageSingle);
689+
testParseExample(ContentExample.imageCluster);
690+
testParseExample(ContentExample.imageClusterThenContent);
691+
testParseExample(ContentExample.imageMultipleClusters);
587692

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

test/widgets/content_test.dart

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

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

0 commit comments

Comments
 (0)