Skip to content

Commit ccc887c

Browse files
content: Support image thumbnails and placeholder
1 parent 42d53c1 commit ccc887c

File tree

4 files changed

+211
-36
lines changed

4 files changed

+211
-36
lines changed

lib/model/content.dart

Lines changed: 65 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -351,26 +351,51 @@ class ImageNodeList extends BlockContentNode {
351351
}
352352

353353
class ImageNode extends BlockContentNode {
354-
const ImageNode({super.debugHtmlNode, required this.srcUrl});
354+
const ImageNode({
355+
super.debugHtmlNode,
356+
required this.srcUrl,
357+
required this.thumbnailUrl,
358+
required this.loading,
359+
});
355360

356-
/// The unmodified `src` attribute for the image.
361+
/// The canonical source URL of the image.
357362
///
358-
/// This may be a relative URL string. It also may not work without adding
363+
/// This may be a relative URL string. It also may not work without adding
359364
/// authentication credentials to the request.
360365
final String srcUrl;
361366

367+
/// The thumbnail URL of the image.
368+
///
369+
/// This may be a relative URL string. It also may not work without adding
370+
/// authentication credentials to the request.
371+
///
372+
/// This will be null if the server hasn't yet generated a thumbnail,
373+
/// or is a version that doesn't offer thumbnails.
374+
/// It will also be null when [loading] is true.
375+
final String? thumbnailUrl;
376+
377+
/// A flag to indicate whether to show the placeholder.
378+
///
379+
/// Typically it will be `true` while Server is generating thumbnails.
380+
final bool loading;
381+
362382
@override
363383
bool operator ==(Object other) {
364-
return other is ImageNode && other.srcUrl == srcUrl;
384+
return other is ImageNode
385+
&& other.srcUrl == srcUrl
386+
&& other.thumbnailUrl == thumbnailUrl
387+
&& other.loading == loading;
365388
}
366389

367390
@override
368-
int get hashCode => Object.hash('ImageNode', srcUrl);
391+
int get hashCode => Object.hash('ImageNode', srcUrl, thumbnailUrl, loading);
369392

370393
@override
371394
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
372395
super.debugFillProperties(properties);
373396
properties.add(StringProperty('srcUrl', srcUrl));
397+
properties.add(StringProperty('thumbnailUrl', thumbnailUrl));
398+
properties.add(FlagProperty('loading', value: loading, ifTrue: "is loading"));
374399
}
375400
}
376401

@@ -1014,7 +1039,7 @@ class _ZulipContentParser {
10141039

10151040
BlockContentNode parseImageNode(dom.Element divElement) {
10161041
assert(_debugParserContext == _ParserContext.block);
1017-
final imgElement = () {
1042+
final elements = () {
10181043
assert(divElement.localName == 'div'
10191044
&& divElement.className == 'message_inline_image');
10201045

@@ -1028,21 +1053,51 @@ class _ZulipContentParser {
10281053
final grandchild = child.nodes[0];
10291054
if (grandchild is! dom.Element) return null;
10301055
if (grandchild.localName != 'img') return null;
1031-
if (grandchild.className.isNotEmpty) return null;
1032-
return grandchild;
1056+
return (child, grandchild);
10331057
}();
10341058

10351059
final debugHtmlNode = kDebugMode ? divElement : null;
1036-
if (imgElement == null) {
1060+
if (elements == null) {
10371061
return UnimplementedBlockContentNode(htmlNode: divElement);
10381062
}
10391063

1064+
final (linkElement, imgElement) = elements;
1065+
final href = linkElement.attributes['href'];
1066+
if (href == null) {
1067+
return UnimplementedBlockContentNode(htmlNode: divElement);
1068+
}
1069+
if (imgElement.className == 'image-loading-placeholder') {
1070+
return ImageNode(
1071+
srcUrl: href,
1072+
thumbnailUrl: null,
1073+
loading: true,
1074+
debugHtmlNode: debugHtmlNode);
1075+
}
10401076
final src = imgElement.attributes['src'];
10411077
if (src == null) {
10421078
return UnimplementedBlockContentNode(htmlNode: divElement);
10431079
}
10441080

1045-
return ImageNode(srcUrl: src, debugHtmlNode: debugHtmlNode);
1081+
final String srcUrl;
1082+
final String? thumbnailUrl;
1083+
if (src.startsWith('/user_uploads/thumbnail/')) {
1084+
srcUrl = href;
1085+
thumbnailUrl = src;
1086+
} else if (src.startsWith('/external_content/')
1087+
|| src.startsWith('https://uploads.zulipusercontent.net/')) {
1088+
srcUrl = src;
1089+
thumbnailUrl = null;
1090+
} else if (href == src) {
1091+
srcUrl = src;
1092+
thumbnailUrl = null;
1093+
} else {
1094+
return UnimplementedBlockContentNode(htmlNode: divElement);
1095+
}
1096+
return ImageNode(
1097+
srcUrl: srcUrl,
1098+
thumbnailUrl: thumbnailUrl,
1099+
loading: false,
1100+
debugHtmlNode: debugHtmlNode);
10461101
}
10471102

10481103
static final _videoClassNameRegexp = () {

lib/widgets/content.dart

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import 'package:flutter/cupertino.dart';
12
import 'package:flutter/foundation.dart';
23
import 'package:flutter/gestures.dart';
34
import 'package:flutter/material.dart';
@@ -571,26 +572,31 @@ class MessageImage extends StatelessWidget {
571572
final message = InheritedMessage.of(context);
572573

573574
// TODO image hover animation
574-
final src = node.srcUrl;
575-
575+
final srcUrl = node.srcUrl;
576+
final thumbnailUrl = node.thumbnailUrl;
576577
final store = PerAccountStoreWidget.of(context);
577-
final resolvedSrc = store.tryResolveUrl(src);
578+
final resolvedSrcUrl = store.tryResolveUrl(srcUrl);
579+
final resolvedThumbnailUrl = thumbnailUrl == null
580+
? null : store.tryResolveUrl(thumbnailUrl);
581+
578582
// TODO if src fails to parse, show an explicit "broken image"
579583

580584
return MessageMediaContainer(
581-
onTap: resolvedSrc == null ? null : () { // TODO(log)
585+
onTap: resolvedSrcUrl == null ? null : () { // TODO(log)
582586
Navigator.of(context).push(getLightboxRoute(
583587
context: context,
584588
message: message,
585-
src: resolvedSrc,
589+
src: resolvedSrcUrl,
586590
mediaType: MediaType.image));
587591
},
588-
child: resolvedSrc == null ? null : LightboxHero(
589-
message: message,
590-
src: resolvedSrc,
591-
child: RealmContentNetworkImage(
592-
resolvedSrc,
593-
filterQuality: FilterQuality.medium)));
592+
child: node.loading
593+
? const CupertinoActivityIndicator()
594+
: resolvedSrcUrl == null ? null : LightboxHero(
595+
message: message,
596+
src: resolvedSrcUrl,
597+
child: RealmContentNetworkImage(
598+
resolvedThumbnailUrl ?? resolvedSrcUrl,
599+
filterQuality: FilterQuality.medium)));
594600
}
595601
}
596602

test/model/content_test.dart

Lines changed: 102 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,8 @@ class ContentExample {
262262
nodes: [TextNode('image')]),
263263
]),
264264
ImageNodeList([
265-
ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3'),
265+
ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3',
266+
thumbnailUrl: null, loading: false),
266267
]),
267268
],
268269
content: [ParagraphNode(links: null, nodes: [TextNode('hello world')])],
@@ -420,12 +421,53 @@ class ContentExample {
420421

421422
static const imageSingle = ContentExample(
422423
'single image',
424+
// https://chat.zulip.org/#narrow/stream/7-test-here/topic/Thumbnails/near/1893590
425+
"[image.jpg](/user_uploads/2/c3/wb9FXk8Ej6qIc28aWKcqUogD/image.jpg)",
426+
'<div class="message_inline_image">'
427+
'<a href="/user_uploads/2/c3/wb9FXk8Ej6qIc28aWKcqUogD/image.jpg" title="image.jpg">'
428+
'<img src="/user_uploads/thumbnail/2/c3/wb9FXk8Ej6qIc28aWKcqUogD/image.jpg/840x560.webp"/></a></div>', [
429+
ImageNodeList([
430+
ImageNode(srcUrl: '/user_uploads/2/c3/wb9FXk8Ej6qIc28aWKcqUogD/image.jpg',
431+
thumbnailUrl: '/user_uploads/thumbnail/2/c3/wb9FXk8Ej6qIc28aWKcqUogD/image.jpg/840x560.webp',
432+
loading: false),
433+
]),
434+
]);
435+
436+
static const imageSingleNoThumbnail = ContentExample(
437+
'single image no thumbnail',
423438
"https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3",
424439
'<div class="message_inline_image">'
425440
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3">'
426441
'<img src="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3"></a></div>', [
427442
ImageNodeList([
428-
ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3'),
443+
ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3',
444+
thumbnailUrl: null, loading: false),
445+
]),
446+
]);
447+
448+
static const imageSingleLoadingPlaceholder = ContentExample(
449+
'single image loading placeholder',
450+
// https://chat.zulip.org/#narrow/stream/7-test-here/topic/Thumbnails/near/1893590
451+
"[image.jpg](/user_uploads/2/c3/wb9FXk8Ej6qIc28aWKcqUogD/image.jpg)",
452+
'<div class="message_inline_image">'
453+
'<a href="/user_uploads/2/c3/wb9FXk8Ej6qIc28aWKcqUogD/image.jpg" title="image.jpg">'
454+
'<img class="image-loading-placeholder" src="/static/images/loading/loader-black.svg"></a></div>', [
455+
ImageNodeList([
456+
ImageNode(srcUrl: '/user_uploads/2/c3/wb9FXk8Ej6qIc28aWKcqUogD/image.jpg',
457+
thumbnailUrl: null, loading: true),
458+
]),
459+
]);
460+
461+
static const imageSingleExternal = ContentExample(
462+
'single image external',
463+
// https://chat.zulip.org/#narrow/stream/7-test-here/topic/Greg/near/1892172
464+
"https://upload.wikimedia.org/wikipedia/commons/7/78/Verregende_bloem_van_een_Helenium_%27El_Dorado%27._22-07-2023._%28d.j.b%29.jpg",
465+
'<div class="message_inline_image">'
466+
'<a href="https://upload.wikimedia.org/wikipedia/commons/7/78/Verregende_bloem_van_een_Helenium_%27El_Dorado%27._22-07-2023._%28d.j.b%29.jpg">'
467+
'<img src="/external_content/de28eb3abf4b7786de4545023dc42d434a2ea0c2/68747470733a2f2f75706c6f61642e77696b696d656469612e6f72672f77696b6970656469612f636f6d6d6f6e732f372f37382f566572726567656e64655f626c6f656d5f76616e5f65656e5f48656c656e69756d5f253237456c5f446f7261646f2532372e5f32322d30372d323032332e5f253238642e6a2e622532392e6a7067"></a></div>', [
468+
ImageNodeList([
469+
ImageNode(srcUrl: '/external_content/de28eb3abf4b7786de4545023dc42d434a2ea0c2/68747470733a2f2f75706c6f61642e77696b696d656469612e6f72672f77696b6970656469612f636f6d6d6f6e732f372f37382f566572726567656e64655f626c6f656d5f76616e5f65656e5f48656c656e69756d5f253237456c5f446f7261646f2532372e5f32322d30372d323032332e5f253238642e6a2e622532392e6a7067',
470+
thumbnailUrl: null, loading: false),
429471
]),
430472
]);
431473

@@ -436,12 +478,41 @@ class ContentExample {
436478
'<a href="::not a URL::">'
437479
'<img src="::not a URL::"></a></div>', [
438480
ImageNodeList([
439-
ImageNode(srcUrl: '::not a URL::'),
481+
ImageNode(srcUrl: '::not a URL::', thumbnailUrl: null, loading: false),
440482
]),
441483
]);
442484

443485
static const imageCluster = ContentExample(
444486
'multiple images',
487+
// https://chat.zulip.org/#narrow/stream/7-test-here/topic/Thumbnails/near/1893154
488+
"[image.jpg](/user_uploads/2/9b/WkDt2Qsy79iwf3sM9EMp9fYL/image.jpg)\n[image2.jpg](/user_uploads/2/70/pVeI52TwFUEoFE2qT_u9AMCO/image2.jpg)",
489+
'<p>'
490+
'<a href="/user_uploads/2/9b/WkDt2Qsy79iwf3sM9EMp9fYL/image.jpg">image.jpg</a><br/>\n'
491+
'<a href="/user_uploads/2/70/pVeI52TwFUEoFE2qT_u9AMCO/image2.jpg">image2.jpg</a></p>\n'
492+
'<div class="message_inline_image">'
493+
'<a href="/user_uploads/2/9b/WkDt2Qsy79iwf3sM9EMp9fYL/image.jpg" title="image.jpg">'
494+
'<img src="/user_uploads/thumbnail/2/9b/WkDt2Qsy79iwf3sM9EMp9fYL/image.jpg/840x560.webp"/></a></div>'
495+
'<div class="message_inline_image">'
496+
'<a href="/user_uploads/2/70/pVeI52TwFUEoFE2qT_u9AMCO/image2.jpg" title="image2.jpg">'
497+
'<img src="/user_uploads/thumbnail/2/70/pVeI52TwFUEoFE2qT_u9AMCO/image2.jpg/840x560.webp"/></a></div>', [
498+
ParagraphNode(links: null, nodes: [
499+
LinkNode(url: '/user_uploads/2/9b/WkDt2Qsy79iwf3sM9EMp9fYL/image.jpg', nodes: [TextNode('image.jpg')]),
500+
LineBreakInlineNode(),
501+
TextNode('\n'),
502+
LinkNode(url: '/user_uploads/2/70/pVeI52TwFUEoFE2qT_u9AMCO/image2.jpg', nodes: [TextNode('image2.jpg')]),
503+
]),
504+
ImageNodeList([
505+
ImageNode(srcUrl: '/user_uploads/2/9b/WkDt2Qsy79iwf3sM9EMp9fYL/image.jpg',
506+
thumbnailUrl: '/user_uploads/thumbnail/2/9b/WkDt2Qsy79iwf3sM9EMp9fYL/image.jpg/840x560.webp',
507+
loading: false),
508+
ImageNode(srcUrl: '/user_uploads/2/70/pVeI52TwFUEoFE2qT_u9AMCO/image2.jpg',
509+
thumbnailUrl: '/user_uploads/thumbnail/2/70/pVeI52TwFUEoFE2qT_u9AMCO/image2.jpg/840x560.webp',
510+
loading: false),
511+
]),
512+
]);
513+
514+
static const imageClusterNoThumbnails = ContentExample(
515+
'multiple images no thumbnails',
445516
"https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3\nhttps://chat.zulip.org/user_avatars/2/realm/icon.png?version=4",
446517
'<p>'
447518
'<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'
@@ -459,8 +530,10 @@ class ContentExample {
459530
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')]),
460531
]),
461532
ImageNodeList([
462-
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/f535ba07f95b99a83aa48e44fd62bbb6c6cf6615/68747470733a2f2f636861742e7a756c69702e6f72672f757365725f617661746172732f322f7265616c6d2f69636f6e2e706e673f76657273696f6e3d33'),
463-
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/8f63bc2632a0e41be3f457d86c077e61b4a03e7e/68747470733a2f2f636861742e7a756c69702e6f72672f757365725f617661746172732f322f7265616c6d2f69636f6e2e706e673f76657273696f6e3d34'),
533+
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/f535ba07f95b99a83aa48e44fd62bbb6c6cf6615/68747470733a2f2f636861742e7a756c69702e6f72672f757365725f617661746172732f322f7265616c6d2f69636f6e2e706e673f76657273696f6e3d33',
534+
thumbnailUrl: null, loading: false),
535+
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/8f63bc2632a0e41be3f457d86c077e61b4a03e7e/68747470733a2f2f636861742e7a756c69702e6f72672f757365725f617661746172732f322f7265616c6d2f69636f6e2e706e673f76657273696f6e3d34',
536+
thumbnailUrl: null, loading: false),
464537
]),
465538
]);
466539

@@ -484,8 +557,10 @@ class ContentExample {
484557
LinkNode(url: 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=2', nodes: [TextNode('icon.png')]),
485558
]),
486559
ImageNodeList([
487-
ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png'),
488-
ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=2'),
560+
ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png',
561+
thumbnailUrl: null, loading: false),
562+
ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=2',
563+
thumbnailUrl: null, loading: false),
489564
]),
490565
ParagraphNode(links: null, nodes: [
491566
TextNode('more content'),
@@ -520,8 +595,10 @@ class ContentExample {
520595
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')]),
521596
]),
522597
ImageNodeList([
523-
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/34b2695ca83af76204b0b25a8f2019ee35ec38fa/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e67'),
524-
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/d200fb112aaccbff9df767373a201fa59601f362/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d31'),
598+
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/34b2695ca83af76204b0b25a8f2019ee35ec38fa/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e67',
599+
thumbnailUrl: null, loading: false),
600+
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/d200fb112aaccbff9df767373a201fa59601f362/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d31',
601+
thumbnailUrl: null, loading: false),
525602
]),
526603
ParagraphNode(links: null, nodes: [
527604
TextNode('Test'),
@@ -533,8 +610,10 @@ class ContentExample {
533610
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')]),
534611
]),
535612
ImageNodeList([
536-
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/c4db87e81348dac94eacaa966b46d968b34029cc/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d32'),
537-
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/51b70540cf6a5b3c8a0b919c893b8abddd447e88/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d33'),
613+
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/c4db87e81348dac94eacaa966b46d968b34029cc/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d32',
614+
thumbnailUrl: null, loading: false),
615+
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/51b70540cf6a5b3c8a0b919c893b8abddd447e88/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d33',
616+
thumbnailUrl: null, loading: false),
538617
]),
539618
]);
540619

@@ -548,7 +627,8 @@ class ContentExample {
548627
'<img src="https://chat.zulip.org/user_avatars/2/realm/icon.png"></a></div></li>\n</ul>', [
549628
ListNode(ListStyle.unordered, [[
550629
ImageNodeList([
551-
ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png'),
630+
ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png',
631+
thumbnailUrl: null, loading: false),
552632
]),
553633
]]),
554634
]);
@@ -573,8 +653,10 @@ class ContentExample {
573653
LinkNode(url: 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=2', nodes: [TextNode('icon.png')]),
574654
]),
575655
ImageNodeList([
576-
ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png'),
577-
ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=2'),
656+
ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png',
657+
thumbnailUrl: null, loading: false),
658+
ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=2',
659+
thumbnailUrl: null, loading: false),
578660
]),
579661
]]),
580662
]);
@@ -597,7 +679,8 @@ class ContentExample {
597679
TextNode(' '),
598680
]),
599681
const ImageNodeList([
600-
ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png'),
682+
ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png',
683+
thumbnailUrl: null, loading: false),
601684
]),
602685
blockUnimplemented('more text'),
603686
]]),
@@ -1034,8 +1117,12 @@ void main() {
10341117
testParseExample(ContentExample.mathBlockInQuote);
10351118

10361119
testParseExample(ContentExample.imageSingle);
1120+
testParseExample(ContentExample.imageSingleNoThumbnail);
1121+
testParseExample(ContentExample.imageSingleLoadingPlaceholder);
1122+
testParseExample(ContentExample.imageSingleExternal);
10371123
testParseExample(ContentExample.imageInvalidUrl);
10381124
testParseExample(ContentExample.imageCluster);
1125+
testParseExample(ContentExample.imageClusterNoThumbnails);
10391126
testParseExample(ContentExample.imageClusterThenContent);
10401127
testParseExample(ContentExample.imageMultipleClusters);
10411128
testParseExample(ContentExample.imageInImplicitParagraph);

0 commit comments

Comments
 (0)