Skip to content

Commit

Permalink
content: Handle clusters of images in parseBlockContentList
Browse files Browse the repository at this point in the history
  • Loading branch information
sirpengi committed Feb 9, 2024
1 parent c5d5eed commit ab9dd8e
Show file tree
Hide file tree
Showing 4 changed files with 227 additions and 15 deletions.
34 changes: 29 additions & 5 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 ImageNodeList extends BlockContentNode {
const ImageNodeList(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 @@ -1031,13 +1042,26 @@ class _ZulipContentParser {

List<BlockContentNode> parseBlockContentList(dom.NodeList nodes) {
assert(_debugParserContext == _ParserContext.block);
final acceptedNodes = nodes.where((node) {
final List<BlockContentNode> result = [];
List<ImageNode> imageNodes = [];
for (final node in nodes) {
// 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);
if (node is dom.Text && (node.text == '\n')) continue;

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

ZulipContent parse(String html) {
Expand Down
17 changes: 15 additions & 2 deletions lib/widgets/content.dart
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ class BlockContentList extends StatelessWidget {
return CodeBlock(node: node);
} else if (node is MathBlockNode) {
return MathBlock(node: node);
} else if (node is ImageNodeList) {
return MessageImageList(node: node);
} else if (node is ImageNode) {
return MessageImage(node: node);
} else if (node is UnimplementedBlockContentNode) {
Expand Down Expand Up @@ -230,6 +232,18 @@ class ListItemWidget extends StatelessWidget {
}
}

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

final ImageNodeList 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 @@ -239,7 +253,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 All @@ -251,7 +264,7 @@ class MessageImage extends StatelessWidget {
Navigator.of(context).push(getLightboxRoute(
context: context, message: message, src: resolvedSrc));
},
child: Align(
child: UnconstrainedBox(
alignment: Alignment.centerLeft,
child: Padding(
// TODO clean up this padding by imitating web less precisely;
Expand Down
121 changes: 113 additions & 8 deletions test/model/content_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,115 @@ class ContentExample {
'<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>'
'<br>\n</p>\n</blockquote>',
[QuotationNode([MathBlockNode(texSource: r'\lambda')])]);

static const singleImage = ContentExample(
'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>', [
ImageNodeList([
ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3'),
]),
]);

static const multipleImages = ContentExample(
'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>', [
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')]),
]),
ImageNodeList([
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/f535ba07f95b99a83aa48e44fd62bbb6c6cf6615/68747470733a2f2f636861742e7a756c69702e6f72672f757365725f617661746172732f322f7265616c6d2f69636f6e2e706e673f76657273696f6e3d33'),
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/8f63bc2632a0e41be3f457d86c077e61b4a03e7e/68747470733a2f2f636861742e7a756c69702e6f72672f757365725f617661746172732f322f7265616c6d2f69636f6e2e706e673f76657273696f6e3d34'),
]),
]);

static const contentAfterImageCluster = ContentExample(
'content after image cluster',
"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",
'<p>content '
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png">icon.png</a> '
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=2">icon.png</a></p>\n'
'<div class="message_inline_image">'
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png" title="icon.png">'
'<img src="https://chat.zulip.org/user_avatars/2/realm/icon.png"></a></div>'
'<div class="message_inline_image">'
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=2" title="icon.png">'
'<img src="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=2"></a></div>'
'<p>more content</p>', [
ParagraphNode(links: null, nodes: [
TextNode('content '),
LinkNode(url: 'https://chat.zulip.org/user_avatars/2/realm/icon.png', nodes: [TextNode('icon.png')]),
TextNode(' '),
LinkNode(url: 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=2', nodes: [TextNode('icon.png')]),
]),
ImageNodeList([
ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png'),
ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=2'),
]),
ParagraphNode(links: null, nodes: [
TextNode('more content'),
]),
]);

static const multipleImageClusters = ContentExample(
'multiple clusters 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>', [
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')]),
]),
ImageNodeList([
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')]),
]),
ImageNodeList([
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/c4db87e81348dac94eacaa966b46d968b34029cc/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d32'),
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/51b70540cf6a5b3c8a0b919c893b8abddd447e88/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d33'),
]),
]);
}

UnimplementedBlockContentNode blockUnimplemented(String html) {
Expand Down Expand Up @@ -572,14 +681,10 @@ void main() {
testParseExample(ContentExample.mathBlock);
testParseExample(ContentExample.mathBlockInQuote);

testParse('parse 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'),
]);
testParseExample(ContentExample.singleImage);
testParseExample(ContentExample.multipleImages);
testParseExample(ContentExample.contentAfterImageCluster);
testParseExample(ContentExample.multipleImageClusters);

testParse('parse nested lists, quotes, headings, code blocks',
// "1. > ###### two\n > * three\n\n four"
Expand Down
70 changes: 70 additions & 0 deletions test/widgets/content_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,76 @@ void main() {
tester.widget(find.textContaining(RegExp(r'^(Tue, Jan 30|Wed, Jan 31), 2024, \d+:\d\d [AP]M$')));
});

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 {
const example = ContentExample.singleImage;
await prepareContent(tester, example.html);
final expectedImages = (example.expectedNodes[0] as ImageNodeList).images;
final images = tester.widgetList<RealmContentNetworkImage>(
find.byType(RealmContentNetworkImage));
check(images.map((i) => i.src.toString()).toList())
.deepEquals(expectedImages.map((n) => n.srcUrl));
});

testWidgets('multiple images', (tester) async {
const example = ContentExample.multipleImages;
await prepareContent(tester, example.html);
final expectedImages = (example.expectedNodes[1] as ImageNodeList).images;
final images = tester.widgetList<RealmContentNetworkImage>(
find.byType(RealmContentNetworkImage));
check(images.map((i) => i.src.toString()).toList())
.deepEquals(expectedImages.map((n) => n.srcUrl));
});

testWidgets('content after image cluster', (tester) async {
const example = ContentExample.contentAfterImageCluster;
await prepareContent(tester, example.html);
final expectedImages = (example.expectedNodes[1] as ImageNodeList).images;
final images = tester.widgetList<RealmContentNetworkImage>(
find.byType(RealmContentNetworkImage));
check(images.map((i) => i.src.toString()).toList())
.deepEquals(expectedImages.map((n) => n.srcUrl));
});

testWidgets('multiple clusters of images', (tester) async {
const example = ContentExample.multipleImageClusters;
await prepareContent(tester, example.html);
final expectedImages = (example.expectedNodes[1] as ImageNodeList).images
+ (example.expectedNodes[4] as ImageNodeList).images;
final images = tester.widgetList<RealmContentNetworkImage>(find.byType(RealmContentNetworkImage));
check(images.map((i) => i.src.toString()).toList())
.deepEquals(expectedImages.map((n) => n.srcUrl));
});
});

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

Expand Down

0 comments on commit ab9dd8e

Please sign in to comment.