Skip to content

Commit

Permalink
content: Combine clusters of images in messages
Browse files Browse the repository at this point in the history
Fixes: zulip#193
  • Loading branch information
sirpengi committed Jan 18, 2024
1 parent 8aac7eb commit 01b8fea
Show file tree
Hide file tree
Showing 4 changed files with 221 additions and 15 deletions.
36 changes: 29 additions & 7 deletions lib/model/content.dart
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,17 @@ class MathBlockNode extends BlockContentNode {
}
}

class ImageNodes extends BlockContentNode {
const ImageNodes(this.images, {super.debugHtmlNode});

final List<ImageNode> images;

@override
List<DiagnosticsNode> debugDescribeChildren() {
return images.map((node) => node.toDiagnosticsNode()).toList();
}
}

class ImageNode extends BlockContentNode {
const ImageNode({super.debugHtmlNode, required this.srcUrl});

Expand Down Expand Up @@ -1013,13 +1024,24 @@ class _ZulipContentParser {

List<BlockContentNode> parseBlockContentList(dom.NodeList nodes) {
assert(_debugParserContext == _ParserContext.block);
final acceptedNodes = nodes.where((node) {
// We get a bunch of newline Text nodes between paragraphs.
// A browser seems to ignore these; let's do the same.
if (node is dom.Text && (node.text == '\n')) return false;
return true;
});
return acceptedNodes.map(parseBlockContent).toList(growable: false);
final List<BlockContentNode> blocks = [];
List<ImageNode> imageNodes = [];
for (final node in nodes) {
if (node is dom.Text && (node.text == '\n')) continue;

final block = parseBlockContent(node);
if (block is ImageNode) {
imageNodes.add(block);
continue;
}
if (imageNodes.isNotEmpty) {
blocks.add(ImageNodes(imageNodes));
imageNodes = [];
}
blocks.add(block);
}
if (imageNodes.isNotEmpty) blocks.add(ImageNodes(imageNodes));
return blocks;
}

ZulipContent parse(String html) {
Expand Down
15 changes: 14 additions & 1 deletion lib/widgets/content.dart
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ class BlockContentList extends StatelessWidget {
return CodeBlock(node: node);
} else if (node is MathBlockNode) {
return MathBlock(node: node);
} else if (node is ImageNodes) {
return MessageImages(node: node);
} else if (node is ImageNode) {
return MessageImage(node: node);
} else if (node is UnimplementedBlockContentNode) {
Expand Down Expand Up @@ -219,6 +221,18 @@ class ListItemWidget extends StatelessWidget {
}
}

class MessageImages extends StatelessWidget {
const MessageImages({super.key, required this.node});

final ImageNodes node;

@override
Widget build(BuildContext context) {
return Wrap(
children: node.images.map((imageNode) => MessageImage(node: imageNode)).toList());
}
}

class MessageImage extends StatelessWidget {
const MessageImage({super.key, required this.node});

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

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

Expand Down
89 changes: 82 additions & 7 deletions test/model/content_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -421,14 +421,89 @@ void main() {
'<br>\n</p>\n</blockquote>',
[const QuotationNode([MathBlockNode(texSource: r'\lambda')])]);

testParse('parse image',
// "https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3"
'<div class="message_inline_image">'
group('Parsing images', () {
testParse('single image',
// "https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3"
'<div class="message_inline_image">'
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3">'
'<img src="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3">'
'</a></div>', const [
ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3'),
]);
'<img src="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3"></a></div>',
const [
ImageNodes([
ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3'),
]),
]);

testParse('parse multiple images',
// "https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3\nhttps://chat.zulip.org/user_avatars/2/realm/icon.png?version=4"
'<p>'
'<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'
'<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'
'<div class="message_inline_image">'
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3">'
'<img src="https://uploads.zulipusercontent.net/f535ba07f95b99a83aa48e44fd62bbb6c6cf6615/68747470733a2f2f636861742e7a756c69702e6f72672f757365725f617661746172732f322f7265616c6d2f69636f6e2e706e673f76657273696f6e3d33"></a></div>'
'<div class="message_inline_image">'
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=4">'
'<img src="https://uploads.zulipusercontent.net/8f63bc2632a0e41be3f457d86c077e61b4a03e7e/68747470733a2f2f636861742e7a756c69702e6f72672f757365725f617661746172732f322f7265616c6d2f69636f6e2e706e673f76657273696f6e3d34"></a></div>',
const [
ParagraphNode(links: null, nodes: [
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')]),
LineBreakInlineNode(),
TextNode('\n'),
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')]),
]),
ImageNodes([
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/f535ba07f95b99a83aa48e44fd62bbb6c6cf6615/68747470733a2f2f636861742e7a756c69702e6f72672f757365725f617661746172732f322f7265616c6d2f69636f6e2e706e673f76657273696f6e3d33'),
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/8f63bc2632a0e41be3f457d86c077e61b4a03e7e/68747470733a2f2f636861742e7a756c69702e6f72672f757365725f617661746172732f322f7265616c6d2f69636f6e2e706e673f76657273696f6e3d34'),
]),
]);

testParse('multiple cluster of images',
// "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"
'<p>'
'<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'
'<div class="message_inline_image">'
'<a href="https://en.wikipedia.org/static/images/icons/wikipedia.png">'
'<img src="https://uploads.zulipusercontent.net/34b2695ca83af76204b0b25a8f2019ee35ec38fa/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e67"></a></div>'
'<div class="message_inline_image">'
'<a href="https://en.wikipedia.org/static/images/icons/wikipedia.png?v=1">'
'<img src="https://uploads.zulipusercontent.net/d200fb112aaccbff9df767373a201fa59601f362/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d31"></a></div>'
'<p>Test</p>\n'
'<p>'
'<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'
'<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'
'<div class="message_inline_image">'
'<a href="https://en.wikipedia.org/static/images/icons/wikipedia.png?v=2">'
'<img src="https://uploads.zulipusercontent.net/c4db87e81348dac94eacaa966b46d968b34029cc/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d32"></a></div>'
'<div class="message_inline_image">'
'<a href="https://en.wikipedia.org/static/images/icons/wikipedia.png?v=3">'
'<img src="https://uploads.zulipusercontent.net/51b70540cf6a5b3c8a0b919c893b8abddd447e88/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d33"></a></div>',
const [
ParagraphNode(links: null, nodes: [
LinkNode(url: 'https://en.wikipedia.org/static/images/icons/wikipedia.png', nodes: [TextNode('https://en.wikipedia.org/static/images/icons/wikipedia.png')]),
LineBreakInlineNode(),
TextNode('\n'),
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')]),
]),
ImageNodes([
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/34b2695ca83af76204b0b25a8f2019ee35ec38fa/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e67'),
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/d200fb112aaccbff9df767373a201fa59601f362/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d31'),
]),
ParagraphNode(links: null, nodes: [
TextNode('Test'),
]),
ParagraphNode(links: null, nodes: [
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')]),
LineBreakInlineNode(),
TextNode('\n'),
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')]),
]),
ImageNodes([
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/c4db87e81348dac94eacaa966b46d968b34029cc/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d32'),
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/51b70540cf6a5b3c8a0b919c893b8abddd447e88/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d33'),
]),
]);
});

testParse('parse nested lists, quotes, headings, code blocks',
// "1. > ###### two\n > * three\n\n four"
Expand Down
96 changes: 96 additions & 0 deletions test/widgets/content_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,102 @@ void main() {
tester.widget(find.text(r'\lambda'));
});

group('MessageImages', () {
final message = eg.streamMessage();

Future<void> prepareContent(WidgetTester tester, String html) async {
addTearDown(testBinding.reset);

await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot());
final httpClient = FakeImageHttpClient();

debugNetworkImageHttpClientProvider = () => httpClient;
httpClient.request.response
..statusCode = HttpStatus.ok
..content = kSolidBlueAvatar;

await tester.pumpWidget(
MaterialApp(
home: Directionality(
textDirection: TextDirection.ltr,
child: GlobalStoreWidget(
child: PerAccountStoreWidget(
accountId: eg.selfAccount.id,
child: MessageContent(
message: message,
content: parseContent(html)))))));
await tester.pump(); // global store
await tester.pump(); // per-account store
debugNetworkImageHttpClientProvider = null;
}

testWidgets('single image', (tester) async {
// "https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3"
await prepareContent(tester,
'<div class="message_inline_image">'
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3">'
'<img src="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3"></a></div>');
tester.widget(find.byType(RealmContentNetworkImage));
final images = tester.widgetList<RealmContentNetworkImage>(find.byType(RealmContentNetworkImage));
check(images.map((i) => i.src.toString()).toList())
.deepEquals([
'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3'
]);
});

testWidgets('parse multiple images', (tester) async {
// "https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3\nhttps://chat.zulip.org/user_avatars/2/realm/icon.png?version=4"
await prepareContent(tester,
'<p>'
'<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'
'<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'
'<div class="message_inline_image">'
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3">'
'<img src="https://uploads.zulipusercontent.net/f535ba07f95b99a83aa48e44fd62bbb6c6cf6615/68747470733a2f2f636861742e7a756c69702e6f72672f757365725f617661746172732f322f7265616c6d2f69636f6e2e706e673f76657273696f6e3d33"></a></div>'
'<div class="message_inline_image">'
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=4">'
'<img src="https://uploads.zulipusercontent.net/8f63bc2632a0e41be3f457d86c077e61b4a03e7e/68747470733a2f2f636861742e7a756c69702e6f72672f757365725f617661746172732f322f7265616c6d2f69636f6e2e706e673f76657273696f6e3d34"></a></div>');
final images = tester.widgetList<RealmContentNetworkImage>(find.byType(RealmContentNetworkImage));
check(images.map((i) => i.src.toString()).toList())
.deepEquals([
'https://uploads.zulipusercontent.net/f535ba07f95b99a83aa48e44fd62bbb6c6cf6615/68747470733a2f2f636861742e7a756c69702e6f72672f757365725f617661746172732f322f7265616c6d2f69636f6e2e706e673f76657273696f6e3d33',
'https://uploads.zulipusercontent.net/8f63bc2632a0e41be3f457d86c077e61b4a03e7e/68747470733a2f2f636861742e7a756c69702e6f72672f757365725f617661746172732f322f7265616c6d2f69636f6e2e706e673f76657273696f6e3d34',
]);
});

testWidgets('multiple cluster of images', (tester) async {
// "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"
await prepareContent(tester,
'<p>'
'<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'
'<div class="message_inline_image">'
'<a href="https://en.wikipedia.org/static/images/icons/wikipedia.png">'
'<img src="https://uploads.zulipusercontent.net/34b2695ca83af76204b0b25a8f2019ee35ec38fa/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e67"></a></div>'
'<div class="message_inline_image">'
'<a href="https://en.wikipedia.org/static/images/icons/wikipedia.png?v=1">'
'<img src="https://uploads.zulipusercontent.net/d200fb112aaccbff9df767373a201fa59601f362/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d31"></a></div>'
'<p>Test</p>\n'
'<p>'
'<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'
'<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'
'<div class="message_inline_image">'
'<a href="https://en.wikipedia.org/static/images/icons/wikipedia.png?v=2">'
'<img src="https://uploads.zulipusercontent.net/c4db87e81348dac94eacaa966b46d968b34029cc/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d32"></a></div>'
'<div class="message_inline_image">'
'<a href="https://en.wikipedia.org/static/images/icons/wikipedia.png?v=3">'
'<img src="https://uploads.zulipusercontent.net/51b70540cf6a5b3c8a0b919c893b8abddd447e88/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d33"></a></div>');
final images = tester.widgetList<RealmContentNetworkImage>(find.byType(RealmContentNetworkImage));
check(images.map((i) => i.src.toString()).toList())
.deepEquals([
'https://uploads.zulipusercontent.net/34b2695ca83af76204b0b25a8f2019ee35ec38fa/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e67',
'https://uploads.zulipusercontent.net/d200fb112aaccbff9df767373a201fa59601f362/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d31',
'https://uploads.zulipusercontent.net/c4db87e81348dac94eacaa966b46d968b34029cc/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d32',
'https://uploads.zulipusercontent.net/51b70540cf6a5b3c8a0b919c893b8abddd447e88/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d33',
]);
});
});

group('RealmContentNetworkImage', () {
final authHeaders = authHeader(email: eg.selfAccount.email, apiKey: eg.selfAccount.apiKey);

Expand Down

0 comments on commit 01b8fea

Please sign in to comment.