Skip to content

Commit 940636a

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

File tree

4 files changed

+195
-36
lines changed

4 files changed

+195
-36
lines changed

lib/model/content.dart

Lines changed: 63 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -351,26 +351,49 @@ 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+
/// It may be null if Server didn't generate a thumbnail, or doesn't support
372+
/// it yet. It will also be null when [loading] is true.
373+
final String? thumbnailUrl;
374+
375+
/// A flag to indicate whether to show the placeholder.
376+
///
377+
/// Typically it will be `true` while Server is generating thumbnails.
378+
final bool loading;
379+
362380
@override
363381
bool operator ==(Object other) {
364-
return other is ImageNode && other.srcUrl == srcUrl;
382+
return other is ImageNode
383+
&& other.srcUrl == srcUrl
384+
&& other.thumbnailUrl == thumbnailUrl
385+
&& other.loading == loading;
365386
}
366387

367388
@override
368-
int get hashCode => Object.hash('ImageNode', srcUrl);
389+
int get hashCode => Object.hash('ImageNode', srcUrl, thumbnailUrl, loading);
369390

370391
@override
371392
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
372393
super.debugFillProperties(properties);
373394
properties.add(StringProperty('srcUrl', srcUrl));
395+
properties.add(StringProperty('thumbnailUrl', thumbnailUrl));
396+
properties.add(FlagProperty('loading', value: loading, ifTrue: "is loading"));
374397
}
375398
}
376399

@@ -1014,7 +1037,7 @@ class _ZulipContentParser {
10141037

10151038
BlockContentNode parseImageNode(dom.Element divElement) {
10161039
assert(_debugParserContext == _ParserContext.block);
1017-
final imgElement = () {
1040+
final elements = () {
10181041
assert(divElement.localName == 'div'
10191042
&& divElement.className == 'message_inline_image');
10201043

@@ -1028,21 +1051,51 @@ class _ZulipContentParser {
10281051
final grandchild = child.nodes[0];
10291052
if (grandchild is! dom.Element) return null;
10301053
if (grandchild.localName != 'img') return null;
1031-
if (grandchild.className.isNotEmpty) return null;
1032-
return grandchild;
1054+
return (child, grandchild);
10331055
}();
10341056

10351057
final debugHtmlNode = kDebugMode ? divElement : null;
1036-
if (imgElement == null) {
1058+
if (elements == null) {
10371059
return UnimplementedBlockContentNode(htmlNode: divElement);
10381060
}
10391061

1062+
final (linkElement, imgElement) = elements;
1063+
final href = linkElement.attributes['href'];
1064+
if (href == null) {
1065+
return UnimplementedBlockContentNode(htmlNode: divElement);
1066+
}
1067+
if (imgElement.className == 'image-loading-placeholder') {
1068+
return ImageNode(
1069+
srcUrl: href,
1070+
thumbnailUrl: null,
1071+
loading: true,
1072+
debugHtmlNode: debugHtmlNode);
1073+
}
10401074
final src = imgElement.attributes['src'];
10411075
if (src == null) {
10421076
return UnimplementedBlockContentNode(htmlNode: divElement);
10431077
}
10441078

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

10481101
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: 88 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,40 @@ 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),
429458
]),
430459
]);
431460

@@ -436,12 +465,41 @@ class ContentExample {
436465
'<a href="::not a URL::">'
437466
'<img src="::not a URL::"></a></div>', [
438467
ImageNodeList([
439-
ImageNode(srcUrl: '::not a URL::'),
468+
ImageNode(srcUrl: '::not a URL::', thumbnailUrl: null, loading: false),
440469
]),
441470
]);
442471

443472
static const imageCluster = ContentExample(
444473
'multiple images',
474+
// https://chat.zulip.org/#narrow/stream/7-test-here/topic/Thumbnails/near/1893154
475+
"[image.jpg](/user_uploads/2/9b/WkDt2Qsy79iwf3sM9EMp9fYL/image.jpg)\n[image2.jpg](/user_uploads/2/70/pVeI52TwFUEoFE2qT_u9AMCO/image2.jpg)",
476+
'<p>'
477+
'<a href="/user_uploads/2/9b/WkDt2Qsy79iwf3sM9EMp9fYL/image.jpg">image.jpg</a><br/>\n'
478+
'<a href="/user_uploads/2/70/pVeI52TwFUEoFE2qT_u9AMCO/image2.jpg">image2.jpg</a></p>\n'
479+
'<div class="message_inline_image">'
480+
'<a href="/user_uploads/2/9b/WkDt2Qsy79iwf3sM9EMp9fYL/image.jpg" title="image.jpg">'
481+
'<img src="/user_uploads/thumbnail/2/9b/WkDt2Qsy79iwf3sM9EMp9fYL/image.jpg/840x560.webp"/></a></div>'
482+
'<div class="message_inline_image">'
483+
'<a href="/user_uploads/2/70/pVeI52TwFUEoFE2qT_u9AMCO/image2.jpg" title="image2.jpg">'
484+
'<img src="/user_uploads/thumbnail/2/70/pVeI52TwFUEoFE2qT_u9AMCO/image2.jpg/840x560.webp"/></a></div>', [
485+
ParagraphNode(links: null, nodes: [
486+
LinkNode(url: '/user_uploads/2/9b/WkDt2Qsy79iwf3sM9EMp9fYL/image.jpg', nodes: [TextNode('image.jpg')]),
487+
LineBreakInlineNode(),
488+
TextNode('\n'),
489+
LinkNode(url: '/user_uploads/2/70/pVeI52TwFUEoFE2qT_u9AMCO/image2.jpg', nodes: [TextNode('image2.jpg')]),
490+
]),
491+
ImageNodeList([
492+
ImageNode(srcUrl: '/user_uploads/2/9b/WkDt2Qsy79iwf3sM9EMp9fYL/image.jpg',
493+
thumbnailUrl: '/user_uploads/thumbnail/2/9b/WkDt2Qsy79iwf3sM9EMp9fYL/image.jpg/840x560.webp',
494+
loading: false),
495+
ImageNode(srcUrl: '/user_uploads/2/70/pVeI52TwFUEoFE2qT_u9AMCO/image2.jpg',
496+
thumbnailUrl: '/user_uploads/thumbnail/2/70/pVeI52TwFUEoFE2qT_u9AMCO/image2.jpg/840x560.webp',
497+
loading: false),
498+
]),
499+
]);
500+
501+
static const imageClusterNoThumbnails = ContentExample(
502+
'multiple images no thumbnails',
445503
"https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3\nhttps://chat.zulip.org/user_avatars/2/realm/icon.png?version=4",
446504
'<p>'
447505
'<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 +517,10 @@ class ContentExample {
459517
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')]),
460518
]),
461519
ImageNodeList([
462-
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/f535ba07f95b99a83aa48e44fd62bbb6c6cf6615/68747470733a2f2f636861742e7a756c69702e6f72672f757365725f617661746172732f322f7265616c6d2f69636f6e2e706e673f76657273696f6e3d33'),
463-
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/8f63bc2632a0e41be3f457d86c077e61b4a03e7e/68747470733a2f2f636861742e7a756c69702e6f72672f757365725f617661746172732f322f7265616c6d2f69636f6e2e706e673f76657273696f6e3d34'),
520+
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/f535ba07f95b99a83aa48e44fd62bbb6c6cf6615/68747470733a2f2f636861742e7a756c69702e6f72672f757365725f617661746172732f322f7265616c6d2f69636f6e2e706e673f76657273696f6e3d33',
521+
thumbnailUrl: null, loading: false),
522+
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/8f63bc2632a0e41be3f457d86c077e61b4a03e7e/68747470733a2f2f636861742e7a756c69702e6f72672f757365725f617661746172732f322f7265616c6d2f69636f6e2e706e673f76657273696f6e3d34',
523+
thumbnailUrl: null, loading: false),
464524
]),
465525
]);
466526

@@ -484,8 +544,10 @@ class ContentExample {
484544
LinkNode(url: 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=2', nodes: [TextNode('icon.png')]),
485545
]),
486546
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'),
547+
ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png',
548+
thumbnailUrl: null, loading: false),
549+
ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=2',
550+
thumbnailUrl: null, loading: false),
489551
]),
490552
ParagraphNode(links: null, nodes: [
491553
TextNode('more content'),
@@ -520,8 +582,10 @@ class ContentExample {
520582
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')]),
521583
]),
522584
ImageNodeList([
523-
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/34b2695ca83af76204b0b25a8f2019ee35ec38fa/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e67'),
524-
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/d200fb112aaccbff9df767373a201fa59601f362/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d31'),
585+
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/34b2695ca83af76204b0b25a8f2019ee35ec38fa/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e67',
586+
thumbnailUrl: null, loading: false),
587+
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/d200fb112aaccbff9df767373a201fa59601f362/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d31',
588+
thumbnailUrl: null, loading: false),
525589
]),
526590
ParagraphNode(links: null, nodes: [
527591
TextNode('Test'),
@@ -533,8 +597,10 @@ class ContentExample {
533597
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')]),
534598
]),
535599
ImageNodeList([
536-
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/c4db87e81348dac94eacaa966b46d968b34029cc/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d32'),
537-
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/51b70540cf6a5b3c8a0b919c893b8abddd447e88/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d33'),
600+
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/c4db87e81348dac94eacaa966b46d968b34029cc/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d32',
601+
thumbnailUrl: null, loading: false),
602+
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/51b70540cf6a5b3c8a0b919c893b8abddd447e88/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d33',
603+
thumbnailUrl: null, loading: false),
538604
]),
539605
]);
540606

@@ -548,7 +614,8 @@ class ContentExample {
548614
'<img src="https://chat.zulip.org/user_avatars/2/realm/icon.png"></a></div></li>\n</ul>', [
549615
ListNode(ListStyle.unordered, [[
550616
ImageNodeList([
551-
ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png'),
617+
ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png',
618+
thumbnailUrl: null, loading: false),
552619
]),
553620
]]),
554621
]);
@@ -573,8 +640,10 @@ class ContentExample {
573640
LinkNode(url: 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=2', nodes: [TextNode('icon.png')]),
574641
]),
575642
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'),
643+
ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png',
644+
thumbnailUrl: null, loading: false),
645+
ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=2',
646+
thumbnailUrl: null, loading: false),
578647
]),
579648
]]),
580649
]);
@@ -597,7 +666,8 @@ class ContentExample {
597666
TextNode(' '),
598667
]),
599668
const ImageNodeList([
600-
ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png'),
669+
ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png',
670+
thumbnailUrl: null, loading: false),
601671
]),
602672
blockUnimplemented('more text'),
603673
]]),
@@ -1034,8 +1104,11 @@ void main() {
10341104
testParseExample(ContentExample.mathBlockInQuote);
10351105

10361106
testParseExample(ContentExample.imageSingle);
1107+
testParseExample(ContentExample.imageSingleNoThumbnail);
1108+
testParseExample(ContentExample.imageSingleLoadingPlaceholder);
10371109
testParseExample(ContentExample.imageInvalidUrl);
10381110
testParseExample(ContentExample.imageCluster);
1111+
testParseExample(ContentExample.imageClusterNoThumbnails);
10391112
testParseExample(ContentExample.imageClusterThenContent);
10401113
testParseExample(ContentExample.imageMultipleClusters);
10411114
testParseExample(ContentExample.imageInImplicitParagraph);

0 commit comments

Comments
 (0)