From 7db3044336a96193c60196ae0e9342e5a8edb036 Mon Sep 17 00:00:00 2001 From: rajveermalviya Date: Fri, 19 Jul 2024 08:04:09 +0530 Subject: [PATCH 1/2] content: Support image thumbnails and placeholder --- lib/model/content.dart | 75 ++++++++++++++++++--- lib/widgets/content.dart | 28 ++++---- test/model/content_test.dart | 117 ++++++++++++++++++++++++++++----- test/widgets/content_test.dart | 27 ++++++++ 4 files changed, 211 insertions(+), 36 deletions(-) diff --git a/lib/model/content.dart b/lib/model/content.dart index 93f057dc1c..a7d8d9a6f7 100644 --- a/lib/model/content.dart +++ b/lib/model/content.dart @@ -351,26 +351,51 @@ class ImageNodeList extends BlockContentNode { } class ImageNode extends BlockContentNode { - const ImageNode({super.debugHtmlNode, required this.srcUrl}); + const ImageNode({ + super.debugHtmlNode, + required this.srcUrl, + required this.thumbnailUrl, + required this.loading, + }); - /// The unmodified `src` attribute for the image. + /// The canonical source URL of the image. /// - /// This may be a relative URL string. It also may not work without adding + /// This may be a relative URL string. It also may not work without adding /// authentication credentials to the request. final String srcUrl; + /// The thumbnail URL of the image. + /// + /// This may be a relative URL string. It also may not work without adding + /// authentication credentials to the request. + /// + /// This will be null if the server hasn't yet generated a thumbnail, + /// or is a version that doesn't offer thumbnails. + /// It will also be null when [loading] is true. + final String? thumbnailUrl; + + /// A flag to indicate whether to show the placeholder. + /// + /// Typically it will be `true` while Server is generating thumbnails. + final bool loading; + @override bool operator ==(Object other) { - return other is ImageNode && other.srcUrl == srcUrl; + return other is ImageNode + && other.srcUrl == srcUrl + && other.thumbnailUrl == thumbnailUrl + && other.loading == loading; } @override - int get hashCode => Object.hash('ImageNode', srcUrl); + int get hashCode => Object.hash('ImageNode', srcUrl, thumbnailUrl, loading); @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); properties.add(StringProperty('srcUrl', srcUrl)); + properties.add(StringProperty('thumbnailUrl', thumbnailUrl)); + properties.add(FlagProperty('loading', value: loading, ifTrue: "is loading")); } } @@ -1014,7 +1039,7 @@ class _ZulipContentParser { BlockContentNode parseImageNode(dom.Element divElement) { assert(_debugParserContext == _ParserContext.block); - final imgElement = () { + final elements = () { assert(divElement.localName == 'div' && divElement.className == 'message_inline_image'); @@ -1028,21 +1053,51 @@ class _ZulipContentParser { final grandchild = child.nodes[0]; if (grandchild is! dom.Element) return null; if (grandchild.localName != 'img') return null; - if (grandchild.className.isNotEmpty) return null; - return grandchild; + return (child, grandchild); }(); final debugHtmlNode = kDebugMode ? divElement : null; - if (imgElement == null) { + if (elements == null) { return UnimplementedBlockContentNode(htmlNode: divElement); } + final (linkElement, imgElement) = elements; + final href = linkElement.attributes['href']; + if (href == null) { + return UnimplementedBlockContentNode(htmlNode: divElement); + } + if (imgElement.className == 'image-loading-placeholder') { + return ImageNode( + srcUrl: href, + thumbnailUrl: null, + loading: true, + debugHtmlNode: debugHtmlNode); + } final src = imgElement.attributes['src']; if (src == null) { return UnimplementedBlockContentNode(htmlNode: divElement); } - return ImageNode(srcUrl: src, debugHtmlNode: debugHtmlNode); + final String srcUrl; + final String? thumbnailUrl; + if (src.startsWith('/user_uploads/thumbnail/')) { + srcUrl = href; + thumbnailUrl = src; + } else if (src.startsWith('/external_content/') + || src.startsWith('https://uploads.zulipusercontent.net/')) { + srcUrl = src; + thumbnailUrl = null; + } else if (href == src) { + srcUrl = src; + thumbnailUrl = null; + } else { + return UnimplementedBlockContentNode(htmlNode: divElement); + } + return ImageNode( + srcUrl: srcUrl, + thumbnailUrl: thumbnailUrl, + loading: false, + debugHtmlNode: debugHtmlNode); } static final _videoClassNameRegexp = () { diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index 43c747232d..4b5d141506 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -1,3 +1,4 @@ +import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; @@ -571,26 +572,31 @@ class MessageImage extends StatelessWidget { final message = InheritedMessage.of(context); // TODO image hover animation - final src = node.srcUrl; - + final srcUrl = node.srcUrl; + final thumbnailUrl = node.thumbnailUrl; final store = PerAccountStoreWidget.of(context); - final resolvedSrc = store.tryResolveUrl(src); + final resolvedSrcUrl = store.tryResolveUrl(srcUrl); + final resolvedThumbnailUrl = thumbnailUrl == null + ? null : store.tryResolveUrl(thumbnailUrl); + // TODO if src fails to parse, show an explicit "broken image" return MessageMediaContainer( - onTap: resolvedSrc == null ? null : () { // TODO(log) + onTap: resolvedSrcUrl == null ? null : () { // TODO(log) Navigator.of(context).push(getLightboxRoute( context: context, message: message, - src: resolvedSrc, + src: resolvedSrcUrl, mediaType: MediaType.image)); }, - child: resolvedSrc == null ? null : LightboxHero( - message: message, - src: resolvedSrc, - child: RealmContentNetworkImage( - resolvedSrc, - filterQuality: FilterQuality.medium))); + child: node.loading + ? const CupertinoActivityIndicator() + : resolvedSrcUrl == null ? null : LightboxHero( + message: message, + src: resolvedSrcUrl, + child: RealmContentNetworkImage( + resolvedThumbnailUrl ?? resolvedSrcUrl, + filterQuality: FilterQuality.medium))); } } diff --git a/test/model/content_test.dart b/test/model/content_test.dart index 3d1aca518a..60a5698c99 100644 --- a/test/model/content_test.dart +++ b/test/model/content_test.dart @@ -262,7 +262,8 @@ class ContentExample { nodes: [TextNode('image')]), ]), ImageNodeList([ - ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3'), + ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3', + thumbnailUrl: null, loading: false), ]), ], content: [ParagraphNode(links: null, nodes: [TextNode('hello world')])], @@ -420,12 +421,53 @@ class ContentExample { static const imageSingle = ContentExample( 'single image', + // https://chat.zulip.org/#narrow/stream/7-test-here/topic/Thumbnails/near/1893590 + "[image.jpg](/user_uploads/2/c3/wb9FXk8Ej6qIc28aWKcqUogD/image.jpg)", + '
' + '' + '
', [ + ImageNodeList([ + ImageNode(srcUrl: '/user_uploads/2/c3/wb9FXk8Ej6qIc28aWKcqUogD/image.jpg', + thumbnailUrl: '/user_uploads/thumbnail/2/c3/wb9FXk8Ej6qIc28aWKcqUogD/image.jpg/840x560.webp', + loading: false), + ]), + ]); + + static const imageSingleNoThumbnail = ContentExample( + 'single image no thumbnail', "https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3", '
' '' '
', [ ImageNodeList([ - ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3'), + ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3', + thumbnailUrl: null, loading: false), + ]), + ]); + + static const imageSingleLoadingPlaceholder = ContentExample( + 'single image loading placeholder', + // https://chat.zulip.org/#narrow/stream/7-test-here/topic/Thumbnails/near/1893590 + "[image.jpg](/user_uploads/2/c3/wb9FXk8Ej6qIc28aWKcqUogD/image.jpg)", + '
' + '' + '
', [ + ImageNodeList([ + ImageNode(srcUrl: '/user_uploads/2/c3/wb9FXk8Ej6qIc28aWKcqUogD/image.jpg', + thumbnailUrl: null, loading: true), + ]), + ]); + + static const imageSingleExternal = ContentExample( + 'single image external', + // https://chat.zulip.org/#narrow/stream/7-test-here/topic/Greg/near/1892172 + "https://upload.wikimedia.org/wikipedia/commons/7/78/Verregende_bloem_van_een_Helenium_%27El_Dorado%27._22-07-2023._%28d.j.b%29.jpg", + '
' + '' + '
', [ + ImageNodeList([ + ImageNode(srcUrl: '/external_content/de28eb3abf4b7786de4545023dc42d434a2ea0c2/68747470733a2f2f75706c6f61642e77696b696d656469612e6f72672f77696b6970656469612f636f6d6d6f6e732f372f37382f566572726567656e64655f626c6f656d5f76616e5f65656e5f48656c656e69756d5f253237456c5f446f7261646f2532372e5f32322d30372d323032332e5f253238642e6a2e622532392e6a7067', + thumbnailUrl: null, loading: false), ]), ]); @@ -436,12 +478,41 @@ class ContentExample { '' '', [ ImageNodeList([ - ImageNode(srcUrl: '::not a URL::'), + ImageNode(srcUrl: '::not a URL::', thumbnailUrl: null, loading: false), ]), ]); static const imageCluster = ContentExample( 'multiple images', + // https://chat.zulip.org/#narrow/stream/7-test-here/topic/Thumbnails/near/1893154 + "[image.jpg](/user_uploads/2/9b/WkDt2Qsy79iwf3sM9EMp9fYL/image.jpg)\n[image2.jpg](/user_uploads/2/70/pVeI52TwFUEoFE2qT_u9AMCO/image2.jpg)", + '

' + 'image.jpg
\n' + 'image2.jpg

\n' + '
' + '' + '
' + '
' + '' + '
', [ + ParagraphNode(links: null, nodes: [ + LinkNode(url: '/user_uploads/2/9b/WkDt2Qsy79iwf3sM9EMp9fYL/image.jpg', nodes: [TextNode('image.jpg')]), + LineBreakInlineNode(), + TextNode('\n'), + LinkNode(url: '/user_uploads/2/70/pVeI52TwFUEoFE2qT_u9AMCO/image2.jpg', nodes: [TextNode('image2.jpg')]), + ]), + ImageNodeList([ + ImageNode(srcUrl: '/user_uploads/2/9b/WkDt2Qsy79iwf3sM9EMp9fYL/image.jpg', + thumbnailUrl: '/user_uploads/thumbnail/2/9b/WkDt2Qsy79iwf3sM9EMp9fYL/image.jpg/840x560.webp', + loading: false), + ImageNode(srcUrl: '/user_uploads/2/70/pVeI52TwFUEoFE2qT_u9AMCO/image2.jpg', + thumbnailUrl: '/user_uploads/thumbnail/2/70/pVeI52TwFUEoFE2qT_u9AMCO/image2.jpg/840x560.webp', + loading: false), + ]), + ]); + + static const imageClusterNoThumbnails = ContentExample( + 'multiple images no thumbnails', "https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3\nhttps://chat.zulip.org/user_avatars/2/realm/icon.png?version=4", '

' 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3
\n' @@ -459,8 +530,10 @@ class ContentExample { 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'), + ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/f535ba07f95b99a83aa48e44fd62bbb6c6cf6615/68747470733a2f2f636861742e7a756c69702e6f72672f757365725f617661746172732f322f7265616c6d2f69636f6e2e706e673f76657273696f6e3d33', + thumbnailUrl: null, loading: false), + ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/8f63bc2632a0e41be3f457d86c077e61b4a03e7e/68747470733a2f2f636861742e7a756c69702e6f72672f757365725f617661746172732f322f7265616c6d2f69636f6e2e706e673f76657273696f6e3d34', + thumbnailUrl: null, loading: false), ]), ]); @@ -484,8 +557,10 @@ class ContentExample { 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'), + ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png', + thumbnailUrl: null, loading: false), + ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=2', + thumbnailUrl: null, loading: false), ]), ParagraphNode(links: null, nodes: [ TextNode('more content'), @@ -520,8 +595,10 @@ class ContentExample { 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'), + ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/34b2695ca83af76204b0b25a8f2019ee35ec38fa/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e67', + thumbnailUrl: null, loading: false), + ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/d200fb112aaccbff9df767373a201fa59601f362/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d31', + thumbnailUrl: null, loading: false), ]), ParagraphNode(links: null, nodes: [ TextNode('Test'), @@ -533,8 +610,10 @@ class ContentExample { 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'), + ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/c4db87e81348dac94eacaa966b46d968b34029cc/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d32', + thumbnailUrl: null, loading: false), + ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/51b70540cf6a5b3c8a0b919c893b8abddd447e88/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d33', + thumbnailUrl: null, loading: false), ]), ]); @@ -548,7 +627,8 @@ class ContentExample { '\n', [ ListNode(ListStyle.unordered, [[ 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', + thumbnailUrl: null, loading: false), ]), ]]), ]); @@ -573,8 +653,10 @@ class ContentExample { 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'), + ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png', + thumbnailUrl: null, loading: false), + ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=2', + thumbnailUrl: null, loading: false), ]), ]]), ]); @@ -597,7 +679,8 @@ class ContentExample { TextNode(' '), ]), const 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', + thumbnailUrl: null, loading: false), ]), blockUnimplemented('more text'), ]]), @@ -1034,8 +1117,12 @@ void main() { testParseExample(ContentExample.mathBlockInQuote); testParseExample(ContentExample.imageSingle); + testParseExample(ContentExample.imageSingleNoThumbnail); + testParseExample(ContentExample.imageSingleLoadingPlaceholder); + testParseExample(ContentExample.imageSingleExternal); testParseExample(ContentExample.imageInvalidUrl); testParseExample(ContentExample.imageCluster); + testParseExample(ContentExample.imageClusterNoThumbnails); testParseExample(ContentExample.imageClusterThenContent); testParseExample(ContentExample.imageMultipleClusters); testParseExample(ContentExample.imageInImplicitParagraph); diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index 996600081b..4831bfa866 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -1,4 +1,5 @@ import 'package:checks/checks.dart'; +import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; @@ -309,12 +310,28 @@ void main() { const example = ContentExample.imageSingle; await prepare(tester, example.html); final expectedImages = (example.expectedNodes[0] as ImageNodeList).images; + final images = tester.widgetList( + find.byType(RealmContentNetworkImage)); + check(images.map((i) => i.src.toString()).toList()) + .deepEquals(expectedImages.map((n) => eg.realmUrl.resolve(n.thumbnailUrl!).toString())); + }); + + testWidgets('single image no thumbnail', (tester) async { + const example = ContentExample.imageSingleNoThumbnail; + await prepare(tester, example.html); + final expectedImages = (example.expectedNodes[0] as ImageNodeList).images; final images = tester.widgetList( find.byType(RealmContentNetworkImage)); check(images.map((i) => i.src.toString()).toList()) .deepEquals(expectedImages.map((n) => n.srcUrl)); }); + testWidgets('single image loading placeholder', (tester) async { + const example = ContentExample.imageSingleLoadingPlaceholder; + await prepare(tester, example.html); + await tester.ensureVisible(find.byType(CupertinoActivityIndicator)); + }); + testWidgets('image with invalid src URL', (tester) async { const example = ContentExample.imageInvalidUrl; await prepare(tester, example.html); @@ -331,6 +348,16 @@ void main() { const example = ContentExample.imageCluster; await prepare(tester, example.html); final expectedImages = (example.expectedNodes[1] as ImageNodeList).images; + final images = tester.widgetList( + find.byType(RealmContentNetworkImage)); + check(images.map((i) => i.src.toString()).toList()) + .deepEquals(expectedImages.map((n) => eg.realmUrl.resolve(n.thumbnailUrl!).toString())); + }); + + testWidgets('multiple images no thumbnails', (tester) async { + const example = ContentExample.imageClusterNoThumbnails; + await prepare(tester, example.html); + final expectedImages = (example.expectedNodes[1] as ImageNodeList).images; final images = tester.widgetList( find.byType(RealmContentNetworkImage)); check(images.map((i) => i.src.toString()).toList()) From 3d93479245078cd3554b9195e6b6a59b1a2c23df Mon Sep 17 00:00:00 2001 From: rajveermalviya Date: Fri, 19 Jul 2024 08:05:40 +0530 Subject: [PATCH 2/2] lightbox: Support thumbnail to original image hero transition Fixes: zulip#799 --- lib/widgets/content.dart | 2 ++ lib/widgets/lightbox.dart | 70 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index 4b5d141506..372facba4b 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -587,6 +587,7 @@ class MessageImage extends StatelessWidget { context: context, message: message, src: resolvedSrcUrl, + thumbnailUrl: resolvedThumbnailUrl, mediaType: MediaType.image)); }, child: node.loading @@ -617,6 +618,7 @@ class MessageInlineVideo extends StatelessWidget { context: context, message: message, src: resolvedSrc, + thumbnailUrl: null, mediaType: MediaType.video)); }, child: Container( diff --git a/lib/widgets/lightbox.dart b/lib/widgets/lightbox.dart index 807fc71ad2..33ed3f68de 100644 --- a/lib/widgets/lightbox.dart +++ b/lib/widgets/lightbox.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; import 'package:flutter_gen/gen_l10n/zulip_localizations.dart'; import 'package:intl/intl.dart'; @@ -91,12 +92,17 @@ class _LightboxPageLayout extends StatefulWidget { const _LightboxPageLayout({ required this.routeEntranceAnimation, required this.message, + required this.buildAppBarBottom, required this.buildBottomAppBar, required this.child, }); final Animation routeEntranceAnimation; final Message message; + + /// For [AppBar.bottom]. + final PreferredSizeWidget? Function(BuildContext context) buildAppBarBottom; + final Widget? Function( BuildContext context, Color color, double elevation) buildBottomAppBar; final Widget child; @@ -171,7 +177,8 @@ class _LightboxPageLayoutState extends State<_LightboxPageLayout> { // Make smaller, like a subtitle style: themeData.textTheme.titleSmall!.copyWith(color: appBarForegroundColor)), - ]))); + ])), + bottom: widget.buildAppBarBottom(context)); } Widget? bottomAppBar; @@ -209,17 +216,30 @@ class _ImageLightboxPage extends StatefulWidget { required this.routeEntranceAnimation, required this.message, required this.src, + required this.thumbnailUrl, }); final Animation routeEntranceAnimation; final Message message; final Uri src; + final Uri? thumbnailUrl; @override State<_ImageLightboxPage> createState() => _ImageLightboxPageState(); } class _ImageLightboxPageState extends State<_ImageLightboxPage> { + double? _loadingProgress; + + PreferredSizeWidget? _buildAppBarBottom(BuildContext context) { + if (_loadingProgress == null) { + return null; + } + return PreferredSize( + preferredSize: const Size.fromHeight(4.0), + child: LinearProgressIndicator(minHeight: 4.0, value: _loadingProgress)); + } + Widget _buildBottomAppBar(BuildContext context, Color color, double elevation) { return BottomAppBar( color: color, @@ -232,11 +252,45 @@ class _ImageLightboxPageState extends State<_ImageLightboxPage> { ); } + Widget _frameBuilder(BuildContext context, Widget child, int? frame, bool wasSynchronouslyLoaded) { + if (widget.thumbnailUrl == null) return child; + + // The full image is available, so display it. + if (frame != null) return child; + + // Display the thumbnail image while original image is downloading. + return RealmContentNetworkImage(widget.thumbnailUrl!, + filterQuality: FilterQuality.medium); + } + + Widget _loadingBuilder(BuildContext context, Widget child, ImageChunkEvent? loadingProgress) { + if (widget.thumbnailUrl == null) return child; + + // `loadingProgress` becomes null when Image has finished downloading. + final double? progress = loadingProgress?.expectedTotalBytes == null ? null + : loadingProgress!.cumulativeBytesLoaded / loadingProgress.expectedTotalBytes!; + + if (progress != _loadingProgress) { + _loadingProgress = progress; + // The [Image.network] API lets us learn progress information only at + // its build time. That's too late for updating the progress indicator, + // so delay that update to the next frame. For discussion, see: + // https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/addPostFrameCallback/near/1893539 + // https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/addPostFrameCallback/near/1894124 + SchedulerBinding.instance.scheduleFrameCallback((_) { + if (!mounted) return; + setState(() {}); + }); + } + return child; + } + @override Widget build(BuildContext context) { return _LightboxPageLayout( routeEntranceAnimation: widget.routeEntranceAnimation, message: widget.message, + buildAppBarBottom: _buildAppBarBottom, buildBottomAppBar: _buildBottomAppBar, child: SizedBox.expand( child: InteractiveViewer( @@ -244,7 +298,14 @@ class _ImageLightboxPageState extends State<_ImageLightboxPage> { child: LightboxHero( message: widget.message, src: widget.src, - child: RealmContentNetworkImage(widget.src, filterQuality: FilterQuality.medium)))))); + child: RealmContentNetworkImage(widget.src, + filterQuality: FilterQuality.medium, + frameBuilder: _frameBuilder, + loadingBuilder: _loadingBuilder), + ), + ), + ), + )); } } @@ -457,6 +518,7 @@ class _VideoLightboxPageState extends State with PerAccountSt return _LightboxPageLayout( routeEntranceAnimation: widget.routeEntranceAnimation, message: widget.message, + buildAppBarBottom: (context) => null, buildBottomAppBar: _buildBottomAppBar, child: SafeArea( child: Center( @@ -484,6 +546,7 @@ Route getLightboxRoute({ BuildContext? context, required Message message, required Uri src, + required Uri? thumbnailUrl, required MediaType mediaType, }) { return AccountPageRouteBuilder( @@ -500,7 +563,8 @@ Route getLightboxRoute({ MediaType.image => _ImageLightboxPage( routeEntranceAnimation: animation, message: message, - src: src), + src: src, + thumbnailUrl: thumbnailUrl), MediaType.video => VideoLightboxPage( routeEntranceAnimation: animation, message: message,