diff --git a/lib/api/model/initial_snapshot.dart b/lib/api/model/initial_snapshot.dart index eeedcde14d..da41a4ca6a 100644 --- a/lib/api/model/initial_snapshot.dart +++ b/lib/api/model/initial_snapshot.dart @@ -117,6 +117,9 @@ class InitialSnapshot { final int maxFileUploadSizeMib; + @JsonKey(defaultValue: []) // TODO(server-9) remove default value + final List serverThumbnailFormats; + final Uri serverEmojiDataUrl; final String? realmEmptyTopicDisplayName; // TODO(server-10) @@ -194,6 +197,7 @@ class InitialSnapshot { required this.realmPresenceDisabled, required this.realmDefaultExternalAccounts, required this.maxFileUploadSizeMib, + required this.serverThumbnailFormats, required this.serverEmojiDataUrl, required this.realmEmptyTopicDisplayName, required this.realmUsers, @@ -262,6 +266,32 @@ class RealmDefaultExternalAccount { Map toJson() => _$RealmDefaultExternalAccountToJson(this); } +/// An item in `server_thumbnail_formats`. +/// +/// For docs, search for "server_thumbnail_formats:" +/// in . +@JsonSerializable(fieldRename: FieldRename.snake) +class ThumbnailFormat { + ThumbnailFormat({ + required this.name, + required this.maxWidth, + required this.maxHeight, + required this.animated, + required this.format, + }); + + final String name; + final int maxWidth; + final int maxHeight; + final bool animated; + final String format; + + factory ThumbnailFormat.fromJson(Map json) => + _$ThumbnailFormatFromJson(json); + + Map toJson() => _$ThumbnailFormatToJson(this); +} + /// An item in `recent_private_conversations`. /// /// For docs, search for "recent_private_conversations:" diff --git a/lib/api/model/initial_snapshot.g.dart b/lib/api/model/initial_snapshot.g.dart index 1c5505a653..93e73d76cb 100644 --- a/lib/api/model/initial_snapshot.g.dart +++ b/lib/api/model/initial_snapshot.g.dart @@ -121,6 +121,11 @@ InitialSnapshot _$InitialSnapshotFromJson( ), ), maxFileUploadSizeMib: (json['max_file_upload_size_mib'] as num).toInt(), + serverThumbnailFormats: + (json['server_thumbnail_formats'] as List?) + ?.map((e) => ThumbnailFormat.fromJson(e as Map)) + .toList() ?? + [], serverEmojiDataUrl: Uri.parse(json['server_emoji_data_url'] as String), realmEmptyTopicDisplayName: json['realm_empty_topic_display_name'] as String?, realmUsers: @@ -194,6 +199,7 @@ Map _$InitialSnapshotToJson( 'realm_presence_disabled': instance.realmPresenceDisabled, 'realm_default_external_accounts': instance.realmDefaultExternalAccounts, 'max_file_upload_size_mib': instance.maxFileUploadSizeMib, + 'server_thumbnail_formats': instance.serverThumbnailFormats, 'server_emoji_data_url': instance.serverEmojiDataUrl.toString(), 'realm_empty_topic_display_name': instance.realmEmptyTopicDisplayName, 'realm_users': instance.realmUsers, @@ -236,6 +242,24 @@ Map _$RealmDefaultExternalAccountToJson( 'url_pattern': instance.urlPattern, }; +ThumbnailFormat _$ThumbnailFormatFromJson(Map json) => + ThumbnailFormat( + name: json['name'] as String, + maxWidth: (json['max_width'] as num).toInt(), + maxHeight: (json['max_height'] as num).toInt(), + animated: json['animated'] as bool, + format: json['format'] as String, + ); + +Map _$ThumbnailFormatToJson(ThumbnailFormat instance) => + { + 'name': instance.name, + 'max_width': instance.maxWidth, + 'max_height': instance.maxHeight, + 'animated': instance.animated, + 'format': instance.format, + }; + RecentDmConversation _$RecentDmConversationFromJson( Map json, ) => RecentDmConversation( diff --git a/lib/model/content.dart b/lib/model/content.dart index 52fa173bc2..86743d05ec 100644 --- a/lib/model/content.dart +++ b/lib/model/content.dart @@ -5,6 +5,7 @@ import 'package:html/parser.dart'; import '../api/model/model.dart'; import '../api/model/submessage.dart'; +import '../widgets/image.dart'; import 'code_block.dart'; import 'katex.dart'; @@ -539,7 +540,7 @@ class ImagePreviewNode extends BlockContentNode { const ImagePreviewNode({ super.debugHtmlNode, required this.srcUrl, - required this.thumbnailUrl, + required this.thumbnail, required this.loading, required this.originalWidth, required this.originalHeight, @@ -551,15 +552,16 @@ class ImagePreviewNode extends BlockContentNode { /// authentication credentials to the request. final String srcUrl; - /// The thumbnail URL of the image. + /// The thumbnail URL of the image and whether it has an animated version. /// - /// This may be a relative URL string. It also may not work without adding - /// authentication credentials to the request. + /// Use [ImageThumbnailLocatorExtension.resolve] to obtain a suitable URL + /// for the current UI need. + /// It 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; + final ImageThumbnailLocator? thumbnail; /// A flag to indicate whether to show the placeholder. /// @@ -576,7 +578,7 @@ class ImagePreviewNode extends BlockContentNode { bool operator ==(Object other) { return other is ImagePreviewNode && other.srcUrl == srcUrl - && other.thumbnailUrl == thumbnailUrl + && other.thumbnail == thumbnail && other.loading == loading && other.originalWidth == originalWidth && other.originalHeight == originalHeight; @@ -584,19 +586,56 @@ class ImagePreviewNode extends BlockContentNode { @override int get hashCode => Object.hash('ImagePreviewNode', - srcUrl, thumbnailUrl, loading, originalWidth, originalHeight); + srcUrl, thumbnail, loading, originalWidth, originalHeight); @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); properties.add(StringProperty('srcUrl', srcUrl)); - properties.add(StringProperty('thumbnailUrl', thumbnailUrl)); + properties.add(DiagnosticsProperty('thumbnail', thumbnail)); properties.add(FlagProperty('loading', value: loading, ifTrue: "is loading")); properties.add(DoubleProperty('originalWidth', originalWidth)); properties.add(DoubleProperty('originalHeight', originalHeight)); } } +/// Data to locate an image thumbnail, +/// and whether the image has an animated version. +/// +/// Use [ImageThumbnailLocatorExtension.resolve] to obtain a suitable URL +/// for the current UI need. +@immutable +class ImageThumbnailLocator extends DiagnosticableTree { + ImageThumbnailLocator({ + required this.urlPath, + required this.hasAnimatedVersion, + }) : assert(urlPath.startsWith(urlPathPrefix)); + + final String urlPath; + final bool hasAnimatedVersion; + + static const urlPathPrefix = '/user_uploads/thumbnail/'; + + @override + bool operator ==(Object other) { + if (other is! ImageThumbnailLocator) return false; + return urlPath == other.urlPath + && hasAnimatedVersion == other.hasAnimatedVersion; + } + + @override + int get hashCode => Object.hash(urlPath, hasAnimatedVersion); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(StringProperty('urlPath', urlPath)); + properties.add(FlagProperty('hasAnimatedVersion', value: hasAnimatedVersion, + ifTrue: 'animatable', + ifFalse: 'not animatable')); + } +} + class InlineVideoNode extends BlockContentNode { const InlineVideoNode({ super.debugHtmlNode, @@ -1399,7 +1438,7 @@ class _ZulipContentParser { if (imgElement.className == 'image-loading-placeholder') { return ImagePreviewNode( srcUrl: href, - thumbnailUrl: null, + thumbnail: null, loading: true, originalWidth: null, originalHeight: null, @@ -1411,19 +1450,21 @@ class _ZulipContentParser { } final String srcUrl; - final String? thumbnailUrl; - if (src.startsWith('/user_uploads/thumbnail/')) { + final ImageThumbnailLocator? thumbnail; + if (src.startsWith(ImageThumbnailLocator.urlPathPrefix)) { // For why we recognize this as the thumbnail form, see discussion: // https://chat.zulip.org/#narrow/channel/412-api-documentation/topic/documenting.20inline.20images/near/2279872 srcUrl = href; - thumbnailUrl = src; + thumbnail = ImageThumbnailLocator( + urlPath: src, + hasAnimatedVersion: imgElement.attributes['data-animated'] == 'true'); } else { // Known cases this handles: // - `src` starts with CAMO_URI, a server variable (e.g. on Zulip Cloud // it's "https://uploads.zulipusercontent.net/" in 2025-10). // - `src` matches `href`, e.g. from pre-thumbnailing servers. srcUrl = src; - thumbnailUrl = null; + thumbnail = null; } double? originalWidth, originalHeight; @@ -1447,7 +1488,7 @@ class _ZulipContentParser { return ImagePreviewNode( srcUrl: srcUrl, - thumbnailUrl: thumbnailUrl, + thumbnail: thumbnail, loading: false, originalWidth: originalWidth, originalHeight: originalHeight, diff --git a/lib/model/realm.dart b/lib/model/realm.dart index 5255e50623..30b41be636 100644 --- a/lib/model/realm.dart +++ b/lib/model/realm.dart @@ -32,6 +32,14 @@ mixin RealmStore on PerAccountStoreBase, UserGroupStore { Duration get serverTypingStartedWaitPeriod => Duration(milliseconds: serverTypingStartedWaitPeriodMilliseconds); int get serverTypingStartedWaitPeriodMilliseconds; + List get serverThumbnailFormats; + /// A digest of [serverThumbnailFormats]: sorted by max resolution, ascending, + /// and filtered to those with `animated: true`. + List get sortedAnimatedThumbnailFormats; + /// A digest of [serverThumbnailFormats]: sorted by max resolution, ascending, + /// and filtered to those with `animated: false`. + List get sortedStillThumbnailFormats; + //|////////////////////////////////////////////////////////////// // Realm settings. @@ -166,6 +174,12 @@ mixin ProxyRealmStore on RealmStore { @override int get serverTypingStartedWaitPeriodMilliseconds => realmStore.serverTypingStartedWaitPeriodMilliseconds; @override + List get serverThumbnailFormats => realmStore.serverThumbnailFormats; + @override + List get sortedAnimatedThumbnailFormats => realmStore.sortedAnimatedThumbnailFormats; + @override + List get sortedStillThumbnailFormats => realmStore.sortedStillThumbnailFormats; + @override bool get realmAllowMessageEditing => realmStore.realmAllowMessageEditing; @override GroupSettingValue? get realmCanDeleteAnyMessageGroup => realmStore.realmCanDeleteAnyMessageGroup; @@ -230,6 +244,11 @@ class RealmStoreImpl extends HasUserGroupStore with RealmStore { serverTypingStartedExpiryPeriodMilliseconds = initialSnapshot.serverTypingStartedExpiryPeriodMilliseconds, serverTypingStoppedWaitPeriodMilliseconds = initialSnapshot.serverTypingStoppedWaitPeriodMilliseconds, serverTypingStartedWaitPeriodMilliseconds = initialSnapshot.serverTypingStartedWaitPeriodMilliseconds, + serverThumbnailFormats = initialSnapshot.serverThumbnailFormats, + _sortedAnimatedThumbnailFormats = _filterAndSortThumbnailFormats( + initialSnapshot.serverThumbnailFormats, animated: true), + _sortedStillThumbnailFormats = _filterAndSortThumbnailFormats( + initialSnapshot.serverThumbnailFormats, animated: false), realmAllowMessageEditing = initialSnapshot.realmAllowMessageEditing, realmCanDeleteAnyMessageGroup = initialSnapshot.realmCanDeleteAnyMessageGroup, realmCanDeleteOwnMessageGroup = initialSnapshot.realmCanDeleteOwnMessageGroup, @@ -374,6 +393,15 @@ class RealmStoreImpl extends HasUserGroupStore with RealmStore { @override final int serverTypingStartedWaitPeriodMilliseconds; + @override + final List serverThumbnailFormats; + @override + List get sortedAnimatedThumbnailFormats => _sortedAnimatedThumbnailFormats; + final List _sortedAnimatedThumbnailFormats; + @override + List get sortedStillThumbnailFormats => _sortedStillThumbnailFormats; + final List _sortedStillThumbnailFormats; + @override final bool realmAllowMessageEditing; @override @@ -432,6 +460,26 @@ class RealmStoreImpl extends HasUserGroupStore with RealmStore { return displayFields.followedBy(nonDisplayFields).toList(); } + static List _filterAndSortThumbnailFormats( + List initialServerThumbnailFormats, { + required bool animated, + }) { + return initialServerThumbnailFormats + .where((format) => format.animated == animated) + .toList() + ..sort(_compareThumbnailFormats); + } + + /// A comparator to sort formats by max resolution, ascending. + /// + /// "Max resolution" means + /// [ThumbnailFormat.maxWidth] * [ThumbnailFormat.maxHeight]. + static int _compareThumbnailFormats(ThumbnailFormat a, ThumbnailFormat b) { + final aMaxResolution = a.maxWidth * a.maxHeight; + final bMaxResolution = b.maxWidth * b.maxHeight; + return aMaxResolution - bMaxResolution; + } + void handleCustomProfileFieldsEvent(CustomProfileFieldsEvent event) { customProfileFields = _sortCustomProfileFields(event.fields); } diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index 85a573cdcb..f723a887bf 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -637,11 +637,12 @@ class MessageImagePreview extends StatelessWidget { // TODO image hover animation final srcUrl = node.srcUrl; - final thumbnailUrl = node.thumbnailUrl; + final thumbnailLocator = node.thumbnail; final store = PerAccountStoreWidget.of(context); final resolvedSrcUrl = store.tryResolveUrl(srcUrl); - final resolvedThumbnailUrl = thumbnailUrl == null - ? null : store.tryResolveUrl(thumbnailUrl); + final resolvedThumbnailUrl = thumbnailLocator?.resolve(context, + width: 150, height: 100, + animationMode: ImageAnimationMode.animateConditionally); // TODO if src fails to parse, show an explicit "broken image" diff --git a/lib/widgets/image.dart b/lib/widgets/image.dart index 9e416da449..fc46f43631 100644 --- a/lib/widgets/image.dart +++ b/lib/widgets/image.dart @@ -2,6 +2,8 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import '../api/core.dart'; +import '../api/model/initial_snapshot.dart'; +import '../model/content.dart'; import 'store.dart'; /// Like [Image.network], but includes [authHeader] if [src] is on-realm. @@ -149,3 +151,54 @@ enum ImageAnimationMode { } } } + +extension ImageThumbnailLocatorExtension on ImageThumbnailLocator { + /// Chooses an appropriate format from [PerAccountStore.serverThumbnailFormats], + /// represented as an absolute URL. + /// + /// Requires an ancestor [PerAccountStoreWidget]. + Uri? resolve( + BuildContext context, { + required int width, + required int height, + required ImageAnimationMode animationMode, + }) { + final store = PerAccountStoreWidget.of(context); + ThumbnailFormat? bestCandidate; + + final animateIfSupported = animationMode.resolve(context); + if (hasAnimatedVersion && animateIfSupported) { + bestCandidate ??= _bestFormatOf(store.sortedAnimatedThumbnailFormats, + width: width, height: height); + } + + bestCandidate ??= _bestFormatOf(store.sortedStillThumbnailFormats, + width: width, height: height); + + + if (bestCandidate == null) { + // Odd if we'd need to fall back to the format encoded in [locator]'s path. + // Seems theoretically possible though: + // maybe this format isn't used now, for new uploads, + // but it was used in the past, including for this image. + return store.realmUrl.replace(path: urlPath); + } + + final lastSlashIndex = urlPath.lastIndexOf('/'); + return store.realmUrl.replace( + path: '${urlPath.substring(0, lastSlashIndex)}/${bestCandidate.name}'); + } + + ThumbnailFormat? _bestFormatOf( + List sortedCandidates, { + required int width, + required int height, + }) { + ThumbnailFormat? result; + for (final candidate in sortedCandidates) { + result = candidate; + if (candidate.maxWidth >= width && candidate.maxHeight >= height) break; + } + return result; + } +} diff --git a/test/example_data.dart b/test/example_data.dart index a6e3e9655d..03aba4a999 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -1346,6 +1346,7 @@ InitialSnapshot initialSnapshot({ bool? realmPresenceDisabled, Map? realmDefaultExternalAccounts, int? maxFileUploadSizeMib, + List? serverThumbnailFormats, Uri? serverEmojiDataUrl, String? realmEmptyTopicDisplayName, List? realmUsers, @@ -1410,6 +1411,7 @@ InitialSnapshot initialSnapshot({ realmPresenceDisabled: realmPresenceDisabled ?? false, realmDefaultExternalAccounts: realmDefaultExternalAccounts ?? {}, maxFileUploadSizeMib: maxFileUploadSizeMib ?? 25, + serverThumbnailFormats: serverThumbnailFormats ?? [], serverEmojiDataUrl: serverEmojiDataUrl ?? realmUrl.replace(path: '/static/emoji.json'), realmEmptyTopicDisplayName: realmEmptyTopicDisplayName ?? defaultRealmEmptyTopicDisplayName, diff --git a/test/model/content_test.dart b/test/model/content_test.dart index bab9b05969..0fa186f8db 100644 --- a/test/model/content_test.dart +++ b/test/model/content_test.dart @@ -26,9 +26,9 @@ import 'content_checks.dart'; // * Then send some test messages, and fetch with a command like this. // (Change "sender" operand to your user ID, and "topic" etc. as desired.) /* $ curl -sS --netrc-file ../.netrc -G https://chat.zulip.org/api/v1/messages \ - --data-urlencode 'narrow=[{"operator":"sender", "operand":2187}, + --data-urlencode 'narrow=[{"operator":"sender", "operand":13313}, {"operator":"stream", "operand":"test here"}, - {"operator":"topic", "operand":"content"}]' \ + {"operator":"topic", "operand":"Thumbnails"}]' \ --data-urlencode anchor=newest --data-urlencode num_before=10 --data-urlencode num_after=0 \ --data-urlencode apply_markdown=true \ | jq '.messages[] | .content' @@ -366,7 +366,7 @@ class ContentExample { ]), ImagePreviewNodeList([ ImagePreviewNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3', - thumbnailUrl: null, loading: false, + thumbnail: null, loading: false, originalWidth: null, originalHeight: null), ]), ], @@ -701,7 +701,7 @@ class ContentExample { ImagePreviewNodeList([ ImagePreviewNode( srcUrl: '/external_content/de28eb3abf4b7786de4545023dc42d434a2ea0c2/68747470733a2f2f75706c6f61642e77696b696d656469612e6f72672f77696b6970656469612f636f6d6d6f6e732f372f37382f566572726567656e64655f626c6f656d5f76616e5f65656e5f48656c656e69756d5f253237456c5f446f7261646f2532372e5f32322d30372d323032332e5f253238642e6a2e622532392e6a7067', - thumbnailUrl: null, + thumbnail: null, loading: false, originalWidth: null, originalHeight: null), @@ -719,14 +719,14 @@ class ContentExample { ImagePreviewNodeList([ ImagePreviewNode( srcUrl: '/external_content/58b0ef9a06d7bb24faec2b11df2f57f476e6f6bb/68747470733a2f2f75706c6f61642e77696b696d656469612e6f72672f77696b6970656469612f636f6d6d6f6e732f7468756d622f372f37312f5a616164706c75697a656e5f76616e5f65656e5f436c656d617469735f746578656e7369735f2532375072696e636573735f4469616e612532372e5f31382d30372d323032335f2532386163746d2e2532395f30322e6a70672f3132383070782d5a616164706c75697a656e5f76616e5f65656e5f436c656d617469735f746578656e7369735f2532375072696e636573735f4469616e612532372e5f31382d30372d323032335f2532386163746d2e2532395f30322e6a7067', - thumbnailUrl: null, + thumbnail: null, loading: false, originalWidth: null, originalHeight: null), ]), ]); - static const imagePreviewSingle = ContentExample( + static final imagePreviewSingle = ContentExample( 'single image preview', // https://chat.zulip.org/#narrow/stream/7-test-here/topic/Thumbnails/near/1900103 "[image.jpg](/user_uploads/2/ce/nvoNL2LaZOciwGZ-FYagddtK/image.jpg)", @@ -735,14 +735,36 @@ class ContentExample { '', [ ImagePreviewNodeList([ ImagePreviewNode(srcUrl: '/user_uploads/2/ce/nvoNL2LaZOciwGZ-FYagddtK/image.jpg', - thumbnailUrl: '/user_uploads/thumbnail/2/ce/nvoNL2LaZOciwGZ-FYagddtK/image.jpg/840x560.webp', + thumbnail: ImageThumbnailLocator( + urlPath: '/user_uploads/thumbnail/2/ce/nvoNL2LaZOciwGZ-FYagddtK/image.jpg/840x560.webp', + hasAnimatedVersion: false, + ), loading: false, originalWidth: 6000, originalHeight: 4000), ]), ]); - static const imagePreviewSingleNoDimensions = ContentExample( + static final imagePreviewSingleAnimatable = ContentExample( + 'single image preview, with animated version', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Thumbnails/near/2298790 + "[2c8d985d.gif](/user_uploads/2/9f/tZ9c5ZmsI_cSDZ6ZdJmW8pt4/2c8d985d.gif)", + '
' + '' + '
', [ + ImagePreviewNodeList([ + ImagePreviewNode(srcUrl: '/user_uploads/2/9f/tZ9c5ZmsI_cSDZ6ZdJmW8pt4/2c8d985d.gif', + thumbnail: ImageThumbnailLocator( + urlPath: '/user_uploads/thumbnail/2/9f/tZ9c5ZmsI_cSDZ6ZdJmW8pt4/2c8d985d.gif/840x560-anim.webp', + hasAnimatedVersion: true, + ), + loading: false, + originalWidth: 64, + originalHeight: 64), + ]), + ]); + + static final imagePreviewSingleNoDimensions = ContentExample( 'single image preview no dimensions', // https://chat.zulip.org/#narrow/stream/7-test-here/topic/Thumbnails/near/1893590 "[image.jpg](/user_uploads/2/c3/wb9FXk8Ej6qIc28aWKcqUogD/image.jpg)", @@ -751,7 +773,10 @@ class ContentExample { '', [ ImagePreviewNodeList([ ImagePreviewNode(srcUrl: '/user_uploads/2/c3/wb9FXk8Ej6qIc28aWKcqUogD/image.jpg', - thumbnailUrl: '/user_uploads/thumbnail/2/c3/wb9FXk8Ej6qIc28aWKcqUogD/image.jpg/840x560.webp', + thumbnail: ImageThumbnailLocator( + urlPath: '/user_uploads/thumbnail/2/c3/wb9FXk8Ej6qIc28aWKcqUogD/image.jpg/840x560.webp', + hasAnimatedVersion: false, + ), loading: false, originalWidth: null, originalHeight: null), @@ -766,7 +791,7 @@ class ContentExample { '', [ ImagePreviewNodeList([ ImagePreviewNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3', - thumbnailUrl: null, loading: false, + thumbnail: null, loading: false, originalWidth: null, originalHeight: null), ]), ]); @@ -780,7 +805,7 @@ class ContentExample { '', [ ImagePreviewNodeList([ ImagePreviewNode(srcUrl: '/user_uploads/2/c3/wb9FXk8Ej6qIc28aWKcqUogD/image.jpg', - thumbnailUrl: null, loading: true, + thumbnail: null, loading: true, originalWidth: null, originalHeight: null), ]), ]); @@ -794,7 +819,7 @@ class ContentExample { '', [ ImagePreviewNodeList([ ImagePreviewNode(srcUrl: '/external_content/de28eb3abf4b7786de4545023dc42d434a2ea0c2/68747470733a2f2f75706c6f61642e77696b696d656469612e6f72672f77696b6970656469612f636f6d6d6f6e732f372f37382f566572726567656e64655f626c6f656d5f76616e5f65656e5f48656c656e69756d5f253237456c5f446f7261646f2532372e5f32322d30372d323032332e5f253238642e6a2e622532392e6a7067', - thumbnailUrl: null, loading: false, + thumbnail: null, loading: false, originalWidth: null, originalHeight: null), ]), ]); @@ -809,7 +834,7 @@ class ContentExample { '', [ ImagePreviewNodeList([ ImagePreviewNode(srcUrl: 'https://uploads.zulipusercontent.net/99742b0f992be15283c428dd42f3b9f5db138d69/68747470733a2f2f75706c6f61642e77696b696d656469612e6f72672f77696b6970656469612f636f6d6d6f6e732f372f37382f566572726567656e64655f626c6f656d5f76616e5f65656e5f48656c656e69756d5f253237456c5f446f7261646f2532372e5f32322d30372d323032332e5f253238642e6a2e622532392e6a7067', - thumbnailUrl: null, loading: false, + thumbnail: null, loading: false, originalWidth: null, originalHeight: null), ]), ]); @@ -824,7 +849,7 @@ class ContentExample { '', [ ImagePreviewNodeList([ ImagePreviewNode(srcUrl: 'https://custom.camo-uri.example/99742b0f992be15283c428dd42f3b9f5db138d69/68747470733a2f2f75706c6f61642e77696b696d656469612e6f72672f77696b6970656469612f636f6d6d6f6e732f372f37382f566572726567656e64655f626c6f656d5f76616e5f65656e5f48656c656e69756d5f253237456c5f446f7261646f2532372e5f32322d30372d323032332e5f253238642e6a2e622532392e6a7067', - thumbnailUrl: null, loading: false, + thumbnail: null, loading: false, originalWidth: null, originalHeight: null), ]), ]); @@ -837,12 +862,12 @@ class ContentExample { '', [ ImagePreviewNodeList([ ImagePreviewNode(srcUrl: '::not a URL::', - thumbnailUrl: null, loading: false, + thumbnail: null, loading: false, originalWidth: null, originalHeight: null), ]), ]); - static const imagePreviewCluster = ContentExample( + static final imagePreviewCluster = ContentExample( 'multiple image previews', // 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)", @@ -863,12 +888,18 @@ class ContentExample { ]), ImagePreviewNodeList([ ImagePreviewNode(srcUrl: '/user_uploads/2/9b/WkDt2Qsy79iwf3sM9EMp9fYL/image.jpg', - thumbnailUrl: '/user_uploads/thumbnail/2/9b/WkDt2Qsy79iwf3sM9EMp9fYL/image.jpg/840x560.webp', + thumbnail: ImageThumbnailLocator( + urlPath: '/user_uploads/thumbnail/2/9b/WkDt2Qsy79iwf3sM9EMp9fYL/image.jpg/840x560.webp', + hasAnimatedVersion: false, + ), loading: false, originalWidth: null, originalHeight: null), ImagePreviewNode(srcUrl: '/user_uploads/2/70/pVeI52TwFUEoFE2qT_u9AMCO/image2.jpg', - thumbnailUrl: '/user_uploads/thumbnail/2/70/pVeI52TwFUEoFE2qT_u9AMCO/image2.jpg/840x560.webp', + thumbnail: ImageThumbnailLocator( + urlPath: '/user_uploads/thumbnail/2/70/pVeI52TwFUEoFE2qT_u9AMCO/image2.jpg/840x560.webp', + hasAnimatedVersion: false, + ), loading: false, originalWidth: null, originalHeight: null), @@ -895,10 +926,10 @@ class ContentExample { ]), ImagePreviewNodeList([ ImagePreviewNode(srcUrl: 'https://uploads.zulipusercontent.net/f535ba07f95b99a83aa48e44fd62bbb6c6cf6615/68747470733a2f2f636861742e7a756c69702e6f72672f757365725f617661746172732f322f7265616c6d2f69636f6e2e706e673f76657273696f6e3d33', - thumbnailUrl: null, loading: false, + thumbnail: null, loading: false, originalWidth: null, originalHeight: null), ImagePreviewNode(srcUrl: 'https://uploads.zulipusercontent.net/8f63bc2632a0e41be3f457d86c077e61b4a03e7e/68747470733a2f2f636861742e7a756c69702e6f72672f757365725f617661746172732f322f7265616c6d2f69636f6e2e706e673f76657273696f6e3d34', - thumbnailUrl: null, loading: false, + thumbnail: null, loading: false, originalWidth: null, originalHeight: null), ]), ]); @@ -924,10 +955,10 @@ class ContentExample { ]), ImagePreviewNodeList([ ImagePreviewNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png', - thumbnailUrl: null, loading: false, + thumbnail: null, loading: false, originalWidth: null, originalHeight: null), ImagePreviewNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=2', - thumbnailUrl: null, loading: false, + thumbnail: null, loading: false, originalWidth: null, originalHeight: null), ]), ParagraphNode(links: null, nodes: [ @@ -964,10 +995,10 @@ class ContentExample { ]), ImagePreviewNodeList([ ImagePreviewNode(srcUrl: 'https://uploads.zulipusercontent.net/34b2695ca83af76204b0b25a8f2019ee35ec38fa/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e67', - thumbnailUrl: null, loading: false, + thumbnail: null, loading: false, originalWidth: null, originalHeight: null), ImagePreviewNode(srcUrl: 'https://uploads.zulipusercontent.net/d200fb112aaccbff9df767373a201fa59601f362/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d31', - thumbnailUrl: null, loading: false, + thumbnail: null, loading: false, originalWidth: null, originalHeight: null), ]), ParagraphNode(links: null, nodes: [ @@ -981,10 +1012,10 @@ class ContentExample { ]), ImagePreviewNodeList([ ImagePreviewNode(srcUrl: 'https://uploads.zulipusercontent.net/c4db87e81348dac94eacaa966b46d968b34029cc/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d32', - thumbnailUrl: null, loading: false, + thumbnail: null, loading: false, originalWidth: null, originalHeight: null), ImagePreviewNode(srcUrl: 'https://uploads.zulipusercontent.net/51b70540cf6a5b3c8a0b919c893b8abddd447e88/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d33', - thumbnailUrl: null, loading: false, + thumbnail: null, loading: false, originalWidth: null, originalHeight: null), ]), ]); @@ -1000,7 +1031,7 @@ class ContentExample { UnorderedListNode([[ ImagePreviewNodeList([ ImagePreviewNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png', - thumbnailUrl: null, loading: false, + thumbnail: null, loading: false, originalWidth: null, originalHeight: null), ]), ]]), @@ -1027,10 +1058,10 @@ class ContentExample { ]), ImagePreviewNodeList([ ImagePreviewNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png', - thumbnailUrl: null, loading: false, + thumbnail: null, loading: false, originalWidth: null, originalHeight: null), ImagePreviewNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=2', - thumbnailUrl: null, loading: false, + thumbnail: null, loading: false, originalWidth: null, originalHeight: null), ]), ]]), @@ -1055,7 +1086,7 @@ class ContentExample { ]), const ImagePreviewNodeList([ ImagePreviewNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png', - thumbnailUrl: null, loading: false, + thumbnail: null, loading: false, originalWidth: null, originalHeight: null), ]), blockUnimplemented('more text'), @@ -1404,7 +1435,7 @@ class ContentExample { ]), ]); - static const tableWithImagePreview = ContentExample( + static final tableWithImagePreview = ContentExample( 'table with image preview', // https://chat.zulip.org/#narrow/channel/7-test-here/topic/.E2.9C.94.20Rajesh/near/1987666 '| a |\n| - |\n| [image2.jpg](/user_uploads/2/6f/KS3vNT9c2tbMfMBkSbQF_Jlj/image2.jpg) |', @@ -1421,7 +1452,10 @@ class ContentExample { ]), ImagePreviewNodeList([ ImagePreviewNode(srcUrl: '/user_uploads/2/6f/KS3vNT9c2tbMfMBkSbQF_Jlj/image2.jpg', - thumbnailUrl: '/user_uploads/thumbnail/2/6f/KS3vNT9c2tbMfMBkSbQF_Jlj/image2.jpg/840x560.webp', + thumbnail: ImageThumbnailLocator( + urlPath: '/user_uploads/thumbnail/2/6f/KS3vNT9c2tbMfMBkSbQF_Jlj/image2.jpg/840x560.webp', + hasAnimatedVersion: false, + ), loading: false, originalWidth: 2760, originalHeight: 4912), @@ -1843,6 +1877,7 @@ void main() async { testParseExample(ContentExample.mathBlockBetweenImagePreviews); testParseExample(ContentExample.imagePreviewSingle); + testParseExample(ContentExample.imagePreviewSingleAnimatable); testParseExample(ContentExample.imagePreviewSingleNoDimensions); testParseExample(ContentExample.imagePreviewSingleNoThumbnail); testParseExample(ContentExample.imagePreviewSingleLoadingPlaceholder); diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index 92fe17796d..ca7c3431a2 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -369,13 +369,14 @@ void main() { } testWidgets('single image', (tester) async { - const example = ContentExample.imagePreviewSingle; + final example = ContentExample.imagePreviewSingle; await prepare(tester, example.html); final expectedImages = (example.expectedNodes[0] as ImagePreviewNodeList).imagePreviews; 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())); + .deepEquals(expectedImages.map( + (n) => eg.realmUrl.resolve(n.thumbnail!.urlPath).toString())); }); testWidgets('single image no thumbnail', (tester) async { @@ -408,13 +409,14 @@ void main() { }); testWidgets('multiple images', (tester) async { - const example = ContentExample.imagePreviewCluster; + final example = ContentExample.imagePreviewCluster; await prepare(tester, example.html); final expectedImages = (example.expectedNodes[1] as ImagePreviewNodeList).imagePreviews; 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())); + .deepEquals(expectedImages.map( + (n) => eg.realmUrl.resolve(n.thumbnail!.urlPath).toString())); }); testWidgets('multiple images no thumbnails', (tester) async { diff --git a/test/widgets/image_test.dart b/test/widgets/image_test.dart index 706793b951..35af2ec442 100644 --- a/test/widgets/image_test.dart +++ b/test/widgets/image_test.dart @@ -2,12 +2,17 @@ import 'package:checks/checks.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:zulip/api/core.dart'; +import 'package:zulip/api/model/initial_snapshot.dart'; +import 'package:zulip/model/content.dart'; +import 'package:zulip/model/store.dart'; import 'package:zulip/widgets/image.dart'; import 'package:zulip/widgets/store.dart'; import '../example_data.dart' as eg; import '../model/binding.dart'; +import '../model/test_store.dart'; import '../test_images.dart'; +import 'test_app.dart'; void main() { TestZulipBinding.ensureInitialized(); @@ -51,4 +56,97 @@ void main() { check(tester.takeException()).isA(); }); }); + + group('ImageThumbnailLocator.resolve', () { + late PerAccountStore store; + + Future prepare(WidgetTester tester) async { + addTearDown(testBinding.reset); + + final exampleFormats = [ + ThumbnailFormat(name: '840x560.webp', + maxWidth: 840, maxHeight: 560, animated: false, format: 'webp'), + ThumbnailFormat(name: '840x560-anim.webp', + maxWidth: 840, maxHeight: 560, animated: true, format: 'webp'), + ThumbnailFormat(name: '500x900.jpg', + maxWidth: 500, maxHeight: 900, animated: false, format: 'jpg'), + ThumbnailFormat(name: '500x900-anim.jpg', + maxWidth: 500, maxHeight: 900, animated: true, format: 'jpg'), + ThumbnailFormat(name: '1000x1000.webp', + maxWidth: 1000, maxHeight: 1000, animated: false, format: 'webp'), + ThumbnailFormat(name: '1000x2000-anim.png', + maxWidth: 1000, maxHeight: 2000, animated: true, format: 'png'), + ThumbnailFormat(name: '1000x1000-anim.webp', + maxWidth: 1000, maxHeight: 1000, animated: true, format: 'webp'), + ThumbnailFormat(name: '1000x2000.png', + maxWidth: 1000, maxHeight: 2000, animated: false, format: 'png'), + ]; + + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot( + serverThumbnailFormats: exampleFormats, + )); + + store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + await store.addUser(eg.selfUser); + + await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id)); + await tester.pump(); + } + + void doCheck( + WidgetTester tester, + int width, + int height, + bool animatedIfSupported, + String expected, { + required bool hasAnimatedVersion, + }) { + final locator = ImageThumbnailLocator( + urlPath: '/user_uploads/thumbnail/1/2/a/pic.jpg/840x560.webp', + hasAnimatedVersion: hasAnimatedVersion, + ); + + final context = tester.element(find.byType(Placeholder)); + final result = locator.resolve(context, + width: width, height: height, + animationMode: animatedIfSupported + ? ImageAnimationMode.animateAlways + : ImageAnimationMode.animateNever); + check(result.toString()).equals(expected); + } + + testWidgets('animated version exists', (tester) async { + await prepare(tester); + + doCheck(tester, 400, 400, false, hasAnimatedVersion: true, + 'https://chat.example/user_uploads/thumbnail/1/2/a/pic.jpg/500x900.jpg'); + doCheck(tester, 500, 900, true, hasAnimatedVersion: true, + 'https://chat.example/user_uploads/thumbnail/1/2/a/pic.jpg/500x900-anim.jpg'); + doCheck(tester, 500, 900, false, hasAnimatedVersion: true, + 'https://chat.example/user_uploads/thumbnail/1/2/a/pic.jpg/500x900.jpg'); + doCheck(tester, 600, 500, true, hasAnimatedVersion: true, + 'https://chat.example/user_uploads/thumbnail/1/2/a/pic.jpg/840x560-anim.webp'); + doCheck(tester, 600, 500, false, hasAnimatedVersion: true, + 'https://chat.example/user_uploads/thumbnail/1/2/a/pic.jpg/840x560.webp'); + doCheck(tester, 1500, 2000, false, hasAnimatedVersion: true, + 'https://chat.example/user_uploads/thumbnail/1/2/a/pic.jpg/1000x2000.png'); + }); + + testWidgets('animated version does not exist', (tester) async { + await prepare(tester); + + doCheck(tester, 400, 400, false, hasAnimatedVersion: false, + 'https://chat.example/user_uploads/thumbnail/1/2/a/pic.jpg/500x900.jpg'); + doCheck(tester, 500, 900, true, hasAnimatedVersion: false, + 'https://chat.example/user_uploads/thumbnail/1/2/a/pic.jpg/500x900.jpg'); + doCheck(tester, 500, 900, false, hasAnimatedVersion: false, + 'https://chat.example/user_uploads/thumbnail/1/2/a/pic.jpg/500x900.jpg'); + doCheck(tester, 600, 500, true, hasAnimatedVersion: false, + 'https://chat.example/user_uploads/thumbnail/1/2/a/pic.jpg/840x560.webp'); + doCheck(tester, 600, 500, false, hasAnimatedVersion: false, + 'https://chat.example/user_uploads/thumbnail/1/2/a/pic.jpg/840x560.webp'); + doCheck(tester, 1500, 2000, false, hasAnimatedVersion: false, + 'https://chat.example/user_uploads/thumbnail/1/2/a/pic.jpg/1000x2000.png'); + }); + }); }