From d6bb85f226cc3269107adad96723c0ced8d3784b Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Mon, 10 Nov 2025 17:03:39 -0800 Subject: [PATCH 1/7] api: Add serverThumbnailFormats to initial snapshot --- lib/api/model/initial_snapshot.dart | 30 +++++++++++++++++++++++++++ lib/api/model/initial_snapshot.g.dart | 24 +++++++++++++++++++++ test/example_data.dart | 2 ++ 3 files changed, 56 insertions(+) diff --git a/lib/api/model/initial_snapshot.dart b/lib/api/model/initial_snapshot.dart index 2f0257eaac..71f91d01b0 100644 --- a/lib/api/model/initial_snapshot.dart +++ b/lib/api/model/initial_snapshot.dart @@ -119,6 +119,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) @@ -197,6 +200,7 @@ class InitialSnapshot { required this.realmPresenceDisabled, required this.realmDefaultExternalAccounts, required this.maxFileUploadSizeMib, + required this.serverThumbnailFormats, required this.serverEmojiDataUrl, required this.realmEmptyTopicDisplayName, required this.realmUsers, @@ -265,6 +269,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 dac7c141f7..cb4d2f00ad 100644 --- a/lib/api/model/initial_snapshot.g.dart +++ b/lib/api/model/initial_snapshot.g.dart @@ -122,6 +122,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: @@ -196,6 +201,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, @@ -238,6 +244,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/test/example_data.dart b/test/example_data.dart index c9beaef72f..dbc7dda2ee 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -1367,6 +1367,7 @@ InitialSnapshot initialSnapshot({ bool? realmPresenceDisabled, Map? realmDefaultExternalAccounts, int? maxFileUploadSizeMib, + List? serverThumbnailFormats, Uri? serverEmojiDataUrl, String? realmEmptyTopicDisplayName, List? realmUsers, @@ -1427,6 +1428,7 @@ InitialSnapshot initialSnapshot({ realmPresenceDisabled: realmPresenceDisabled ?? false, realmDefaultExternalAccounts: realmDefaultExternalAccounts ?? {}, maxFileUploadSizeMib: maxFileUploadSizeMib ?? 25, + serverThumbnailFormats: serverThumbnailFormats ?? [], serverEmojiDataUrl: serverEmojiDataUrl ?? realmUrl.replace(path: '/static/emoji.json'), realmEmptyTopicDisplayName: realmEmptyTopicDisplayName ?? defaultRealmEmptyTopicDisplayName, From 634439d90085515dc8a1053030c37eaa54eb6f8d Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Mon, 10 Nov 2025 17:23:47 -0800 Subject: [PATCH 2/7] store: Add RealmStore.serverThumbnailFormats --- lib/model/realm.dart | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/model/realm.dart b/lib/model/realm.dart index 9acfbda757..d78fcf825f 100644 --- a/lib/model/realm.dart +++ b/lib/model/realm.dart @@ -32,6 +32,8 @@ mixin RealmStore on PerAccountStoreBase, UserGroupStore { Duration get serverTypingStartedWaitPeriod => Duration(milliseconds: serverTypingStartedWaitPeriodMilliseconds); int get serverTypingStartedWaitPeriodMilliseconds; + List get serverThumbnailFormats; + //|////////////////////////////////////////////////////////////// // Realm settings. @@ -167,6 +169,8 @@ mixin ProxyRealmStore on RealmStore { @override int get serverTypingStartedWaitPeriodMilliseconds => realmStore.serverTypingStartedWaitPeriodMilliseconds; @override + List get serverThumbnailFormats => realmStore.serverThumbnailFormats; + @override bool get realmAllowMessageEditing => realmStore.realmAllowMessageEditing; @override GroupSettingValue? get realmCanDeleteAnyMessageGroup => realmStore.realmCanDeleteAnyMessageGroup; @@ -233,6 +237,7 @@ class RealmStoreImpl extends HasUserGroupStore with RealmStore { serverTypingStartedExpiryPeriodMilliseconds = initialSnapshot.serverTypingStartedExpiryPeriodMilliseconds, serverTypingStoppedWaitPeriodMilliseconds = initialSnapshot.serverTypingStoppedWaitPeriodMilliseconds, serverTypingStartedWaitPeriodMilliseconds = initialSnapshot.serverTypingStartedWaitPeriodMilliseconds, + serverThumbnailFormats = initialSnapshot.serverThumbnailFormats, realmAllowMessageEditing = initialSnapshot.realmAllowMessageEditing, realmCanDeleteAnyMessageGroup = initialSnapshot.realmCanDeleteAnyMessageGroup, realmCanDeleteOwnMessageGroup = initialSnapshot.realmCanDeleteOwnMessageGroup, @@ -378,6 +383,9 @@ class RealmStoreImpl extends HasUserGroupStore with RealmStore { @override final int serverTypingStartedWaitPeriodMilliseconds; + @override + final List serverThumbnailFormats; + @override final bool realmAllowMessageEditing; @override From 22d589a5dc04436d72cc77b3a06ac46b17cbf8ae Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Fri, 5 Dec 2025 17:01:11 -0800 Subject: [PATCH 3/7] content: Add ImageThumbnailLocator, wrapping thumbnail URLs Soon we'll add a field on this for the `data-animated` attribute, then write some logic to help UI code choose a thumbnail format from RealmStore.serverThumbnailFormats. Not NFC just because we've added a restriction on `src` for a thumbnail URL in an image preview: now it must go through Uri.parse without error, else we'll show an "unimplemented content" block. --- lib/model/content.dart | 64 ++++++++++++++++++++------ lib/widgets/content.dart | 4 +- test/model/content_test.dart | 83 ++++++++++++++++++++++------------ test/widgets/content_test.dart | 10 ++-- 4 files changed, 111 insertions(+), 50 deletions(-) diff --git a/lib/model/content.dart b/lib/model/content.dart index 52fa173bc2..917583bb8f 100644 --- a/lib/model/content.dart +++ b/lib/model/content.dart @@ -539,7 +539,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, @@ -553,13 +553,10 @@ class ImagePreviewNode extends BlockContentNode { /// 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; + final ImageThumbnailLocator? thumbnail; /// A flag to indicate whether to show the placeholder. /// @@ -576,7 +573,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 +581,55 @@ 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. +/// +/// Currently a no-op wrapper around a thumbnail URL ([defaultFormatSrc]). +/// Soon, this class will support choosing a format for the caller's UI need, +/// from [RealmStore.serverThumbnailFormats]. +/// Until then, callers should just use [defaultFormatSrc]. +@immutable +class ImageThumbnailLocator extends DiagnosticableTree { + ImageThumbnailLocator({ + required this.defaultFormatSrc, + }) : assert(!defaultFormatSrc.isAbsolute), + assert(defaultFormatSrc.path.startsWith(srcPrefix)); + + /// A relative URL for the default format, starting with [srcPrefix]. + /// + /// It may not work without adding authentication credentials to the request. + final Uri defaultFormatSrc; + + static const srcPrefix = '/user_uploads/thumbnail/'; + + @override + bool operator ==(Object other) { + if (other is! ImageThumbnailLocator) return false; + return defaultFormatSrc == other.defaultFormatSrc; + } + + @override + int get hashCode => Object.hash('ImageThumbnailLocator', defaultFormatSrc); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(StringProperty('defaultFormatSrc', defaultFormatSrc.toString())); + } +} + class InlineVideoNode extends BlockContentNode { const InlineVideoNode({ super.debugHtmlNode, @@ -1399,7 +1432,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 +1444,22 @@ class _ZulipContentParser { } final String srcUrl; - final String? thumbnailUrl; - if (src.startsWith('/user_uploads/thumbnail/')) { + final ImageThumbnailLocator? thumbnail; + if (src.startsWith(ImageThumbnailLocator.srcPrefix)) { + final parsedSrc = Uri.tryParse(src); + if (parsedSrc == null) return UnimplementedBlockContentNode(htmlNode: divElement); + // 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(defaultFormatSrc: parsedSrc); } 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 +1483,7 @@ class _ZulipContentParser { return ImagePreviewNode( srcUrl: srcUrl, - thumbnailUrl: thumbnailUrl, + thumbnail: thumbnail, loading: false, originalWidth: originalWidth, originalHeight: originalHeight, diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index e4a77a77f0..2b6388c700 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -637,11 +637,11 @@ class MessageImagePreview extends StatelessWidget { // TODO image hover animation final srcUrl = node.srcUrl; - final thumbnailUrl = node.thumbnailUrl; + final thumbnailUrl = node.thumbnail?.defaultFormatSrc; final store = PerAccountStoreWidget.of(context); final resolvedSrcUrl = store.tryResolveUrl(srcUrl); final resolvedThumbnailUrl = thumbnailUrl == null - ? null : store.tryResolveUrl(thumbnailUrl); + ? null : store.tryResolveUrl(thumbnailUrl.toString()); // TODO if src fails to parse, show an explicit "broken image" diff --git a/test/model/content_test.dart b/test/model/content_test.dart index bab9b05969..44e8312c53 100644 --- a/test/model/content_test.dart +++ b/test/model/content_test.dart @@ -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,15 @@ 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( + defaultFormatSrc: Uri.parse('/user_uploads/thumbnail/2/ce/nvoNL2LaZOciwGZ-FYagddtK/image.jpg/840x560.webp')), loading: false, originalWidth: 6000, originalHeight: 4000), ]), ]); - static const imagePreviewSingleNoDimensions = ContentExample( + 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 +752,8 @@ 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( + defaultFormatSrc: Uri.parse('/user_uploads/thumbnail/2/c3/wb9FXk8Ej6qIc28aWKcqUogD/image.jpg/840x560.webp')), loading: false, originalWidth: null, originalHeight: null), @@ -766,7 +768,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 +782,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 +796,7 @@ class ContentExample { '', [ ImagePreviewNodeList([ ImagePreviewNode(srcUrl: '/external_content/de28eb3abf4b7786de4545023dc42d434a2ea0c2/68747470733a2f2f75706c6f61642e77696b696d656469612e6f72672f77696b6970656469612f636f6d6d6f6e732f372f37382f566572726567656e64655f626c6f656d5f76616e5f65656e5f48656c656e69756d5f253237456c5f446f7261646f2532372e5f32322d30372d323032332e5f253238642e6a2e622532392e6a7067', - thumbnailUrl: null, loading: false, + thumbnail: null, loading: false, originalWidth: null, originalHeight: null), ]), ]); @@ -809,7 +811,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 +826,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 +839,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 +865,14 @@ 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( + defaultFormatSrc: Uri.parse('/user_uploads/thumbnail/2/9b/WkDt2Qsy79iwf3sM9EMp9fYL/image.jpg/840x560.webp')), 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( + defaultFormatSrc: Uri.parse('/user_uploads/thumbnail/2/70/pVeI52TwFUEoFE2qT_u9AMCO/image2.jpg/840x560.webp')), loading: false, originalWidth: null, originalHeight: null), @@ -895,10 +899,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 +928,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 +968,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 +985,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 +1004,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 +1031,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 +1059,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 +1408,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 +1425,8 @@ 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( + defaultFormatSrc: Uri.parse('/user_uploads/thumbnail/2/6f/KS3vNT9c2tbMfMBkSbQF_Jlj/image2.jpg/840x560.webp')), loading: false, originalWidth: 2760, originalHeight: 4912), @@ -1843,6 +1848,24 @@ void main() async { testParseExample(ContentExample.mathBlockBetweenImagePreviews); testParseExample(ContentExample.imagePreviewSingle); + + testParse('image preview: if thumbnail URL has query and fragment, accept and preserve them', + // Hypothetical server behavior, so there's no example message to point to. + // Discussion: https://chat.zulip.org/#narrow/channel/412-api-documentation/topic/.60server_thumbnail_formats.60.20in.20register.20response/near/2324602 + '
' + '' + '
', + [ + ImagePreviewNodeList([ + ImagePreviewNode(srcUrl: '/user_uploads/2/ce/nvoNL2LaZOciwGZ-FYagddtK/image.jpg', + thumbnail: ImageThumbnailLocator( + defaultFormatSrc: Uri.parse('/user_uploads/thumbnail/2/ce/nvoNL2LaZOciwGZ-FYagddtK/image.jpg/840x560.webp?x=y#abc')), + loading: false, + originalWidth: 6000, + originalHeight: 4000), + ]), + ]); + 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..63b338a7b1 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!.defaultFormatSrc.toString()).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!.defaultFormatSrc.toString()).toString())); }); testWidgets('multiple images no thumbnails', (tester) async { From c26dbb8d8f2cb8873f3d1c8dbf122df410f2604c Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 12 Nov 2025 17:28:22 -0800 Subject: [PATCH 4/7] content: Parse `data-animated` on image-preview HTML --- lib/model/content.dart | 20 +++++++++++++++----- test/model/content_test.dart | 30 ++++++++++++++++++++++++------ 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/lib/model/content.dart b/lib/model/content.dart index 917583bb8f..6ea3c483bc 100644 --- a/lib/model/content.dart +++ b/lib/model/content.dart @@ -551,7 +551,7 @@ 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 will be null if the server hasn't yet generated a thumbnail, /// or is a version that doesn't offer thumbnails. @@ -594,7 +594,8 @@ class ImagePreviewNode extends BlockContentNode { } } -/// Data to locate an image thumbnail. +/// Data to locate an image thumbnail, +/// and whether the image has an animated version. /// /// Currently a no-op wrapper around a thumbnail URL ([defaultFormatSrc]). /// Soon, this class will support choosing a format for the caller's UI need, @@ -604,6 +605,7 @@ class ImagePreviewNode extends BlockContentNode { class ImageThumbnailLocator extends DiagnosticableTree { ImageThumbnailLocator({ required this.defaultFormatSrc, + required this.animated, }) : assert(!defaultFormatSrc.isAbsolute), assert(defaultFormatSrc.path.startsWith(srcPrefix)); @@ -612,21 +614,27 @@ class ImageThumbnailLocator extends DiagnosticableTree { /// It may not work without adding authentication credentials to the request. final Uri defaultFormatSrc; + final bool animated; + static const srcPrefix = '/user_uploads/thumbnail/'; @override bool operator ==(Object other) { if (other is! ImageThumbnailLocator) return false; - return defaultFormatSrc == other.defaultFormatSrc; + return defaultFormatSrc == other.defaultFormatSrc + && animated == other.animated; } @override - int get hashCode => Object.hash('ImageThumbnailLocator', defaultFormatSrc); + int get hashCode => Object.hash('ImageThumbnailLocator', defaultFormatSrc, animated); @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); properties.add(StringProperty('defaultFormatSrc', defaultFormatSrc.toString())); + properties.add(FlagProperty('animated', value: animated, + ifTrue: 'animated', + ifFalse: 'not animated')); } } @@ -1452,7 +1460,9 @@ class _ZulipContentParser { // 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; - thumbnail = ImageThumbnailLocator(defaultFormatSrc: parsedSrc); + thumbnail = ImageThumbnailLocator( + defaultFormatSrc: parsedSrc, + animated: imgElement.attributes['data-animated'] == 'true'); } else { // Known cases this handles: // - `src` starts with CAMO_URI, a server variable (e.g. on Zulip Cloud diff --git a/test/model/content_test.dart b/test/model/content_test.dart index 44e8312c53..2b76bf62f9 100644 --- a/test/model/content_test.dart +++ b/test/model/content_test.dart @@ -735,7 +735,7 @@ class ContentExample { '', [ ImagePreviewNodeList([ ImagePreviewNode(srcUrl: '/user_uploads/2/ce/nvoNL2LaZOciwGZ-FYagddtK/image.jpg', - thumbnail: ImageThumbnailLocator( + thumbnail: ImageThumbnailLocator(animated: false, defaultFormatSrc: Uri.parse('/user_uploads/thumbnail/2/ce/nvoNL2LaZOciwGZ-FYagddtK/image.jpg/840x560.webp')), loading: false, originalWidth: 6000, @@ -743,6 +743,23 @@ class ContentExample { ]), ]); + static final imagePreviewSingleAnimated = ContentExample( + 'single image preview, animated', + // 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(animated: true, + defaultFormatSrc: Uri.parse('/user_uploads/thumbnail/2/9f/tZ9c5ZmsI_cSDZ6ZdJmW8pt4/2c8d985d.gif/840x560-anim.webp')), + 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 @@ -752,7 +769,7 @@ class ContentExample { '', [ ImagePreviewNodeList([ ImagePreviewNode(srcUrl: '/user_uploads/2/c3/wb9FXk8Ej6qIc28aWKcqUogD/image.jpg', - thumbnail: ImageThumbnailLocator( + thumbnail: ImageThumbnailLocator(animated: false, defaultFormatSrc: Uri.parse('/user_uploads/thumbnail/2/c3/wb9FXk8Ej6qIc28aWKcqUogD/image.jpg/840x560.webp')), loading: false, originalWidth: null, @@ -865,13 +882,13 @@ class ContentExample { ]), ImagePreviewNodeList([ ImagePreviewNode(srcUrl: '/user_uploads/2/9b/WkDt2Qsy79iwf3sM9EMp9fYL/image.jpg', - thumbnail: ImageThumbnailLocator( + thumbnail: ImageThumbnailLocator(animated: false, defaultFormatSrc: Uri.parse('/user_uploads/thumbnail/2/9b/WkDt2Qsy79iwf3sM9EMp9fYL/image.jpg/840x560.webp')), loading: false, originalWidth: null, originalHeight: null), ImagePreviewNode(srcUrl: '/user_uploads/2/70/pVeI52TwFUEoFE2qT_u9AMCO/image2.jpg', - thumbnail: ImageThumbnailLocator( + thumbnail: ImageThumbnailLocator(animated: false, defaultFormatSrc: Uri.parse('/user_uploads/thumbnail/2/70/pVeI52TwFUEoFE2qT_u9AMCO/image2.jpg/840x560.webp')), loading: false, originalWidth: null, @@ -1425,7 +1442,7 @@ class ContentExample { ]), ImagePreviewNodeList([ ImagePreviewNode(srcUrl: '/user_uploads/2/6f/KS3vNT9c2tbMfMBkSbQF_Jlj/image2.jpg', - thumbnail: ImageThumbnailLocator( + thumbnail: ImageThumbnailLocator(animated: false, defaultFormatSrc: Uri.parse('/user_uploads/thumbnail/2/6f/KS3vNT9c2tbMfMBkSbQF_Jlj/image2.jpg/840x560.webp')), loading: false, originalWidth: 2760, @@ -1858,7 +1875,7 @@ void main() async { [ ImagePreviewNodeList([ ImagePreviewNode(srcUrl: '/user_uploads/2/ce/nvoNL2LaZOciwGZ-FYagddtK/image.jpg', - thumbnail: ImageThumbnailLocator( + thumbnail: ImageThumbnailLocator(animated: false, defaultFormatSrc: Uri.parse('/user_uploads/thumbnail/2/ce/nvoNL2LaZOciwGZ-FYagddtK/image.jpg/840x560.webp?x=y#abc')), loading: false, originalWidth: 6000, @@ -1866,6 +1883,7 @@ void main() async { ]), ]); + testParseExample(ContentExample.imagePreviewSingleAnimated); testParseExample(ContentExample.imagePreviewSingleNoDimensions); testParseExample(ContentExample.imagePreviewSingleNoThumbnail); testParseExample(ContentExample.imagePreviewSingleLoadingPlaceholder); From 2a11e040dc75a78190ed724fb7d6d25f536aa3db Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Fri, 5 Dec 2025 17:16:39 -0800 Subject: [PATCH 5/7] image [nfc]: Rename ImageAnimationMode.resolve to shouldAnimate As suggested by Greg: https://github.com/zulip/zulip-flutter/pull/1987#discussion_r2551931290 --- lib/widgets/emoji.dart | 2 +- lib/widgets/image.dart | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/widgets/emoji.dart b/lib/widgets/emoji.dart index 4bbc70925f..beee55c981 100644 --- a/lib/widgets/emoji.dart +++ b/lib/widgets/emoji.dart @@ -214,7 +214,7 @@ class ImageEmojiWidget extends StatelessWidget { Widget build(BuildContext context) { final size = textScaler.scale(this.size); - final resolvedUrl = animationMode.resolve(context) + final resolvedUrl = animationMode.shouldAnimate(context) ? emojiDisplay.resolvedUrl : (emojiDisplay.resolvedStillUrl ?? emojiDisplay.resolvedUrl); diff --git a/lib/widgets/image.dart b/lib/widgets/image.dart index 9e416da449..972d9b82b7 100644 --- a/lib/widgets/image.dart +++ b/lib/widgets/image.dart @@ -111,7 +111,7 @@ class RealmContentNetworkImage extends StatelessWidget { /// Whether to show an animated image in its still or animated version. /// -/// Use [resolve] to evaluate this for the given [BuildContext], +/// Use [shouldAnimate] to evaluate this for the given [BuildContext], /// which reads device-setting data for [animateConditionally]. enum ImageAnimationMode { /// Always show the animated version. @@ -126,7 +126,7 @@ enum ImageAnimationMode { ; /// True if the image should be animated, false if it should be still. - bool resolve(BuildContext context) { + bool shouldAnimate(BuildContext context) { switch (this) { case animateAlways: return true; case animateNever: return false; From 6885cc1c382c9b60a75775fa53dffe2c42d74725 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Mon, 10 Nov 2025 17:03:33 -0800 Subject: [PATCH 6/7] content: Use RealmStore.serverThumbnailFormats for thumbnails Fixes #1936. --- lib/model/content.dart | 7 +- lib/model/realm.dart | 39 ++++++++++ lib/widgets/content.dart | 18 +++-- lib/widgets/image.dart | 64 ++++++++++++++++ test/widgets/image_test.dart | 137 +++++++++++++++++++++++++++++++++++ 5 files changed, 256 insertions(+), 9 deletions(-) diff --git a/lib/model/content.dart b/lib/model/content.dart index 6ea3c483bc..75f5b7d5ac 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'; @@ -597,10 +598,8 @@ class ImagePreviewNode extends BlockContentNode { /// Data to locate an image thumbnail, /// and whether the image has an animated version. /// -/// Currently a no-op wrapper around a thumbnail URL ([defaultFormatSrc]). -/// Soon, this class will support choosing a format for the caller's UI need, -/// from [RealmStore.serverThumbnailFormats]. -/// Until then, callers should just use [defaultFormatSrc]. +/// Use [ImageThumbnailLocatorExtension.resolve] to obtain a suitable URL +/// for the current UI need. @immutable class ImageThumbnailLocator extends DiagnosticableTree { ImageThumbnailLocator({ diff --git a/lib/model/realm.dart b/lib/model/realm.dart index d78fcf825f..4c96000450 100644 --- a/lib/model/realm.dart +++ b/lib/model/realm.dart @@ -33,6 +33,14 @@ mixin RealmStore on PerAccountStoreBase, UserGroupStore { int get serverTypingStartedWaitPeriodMilliseconds; List get serverThumbnailFormats; + /// A digest of [serverThumbnailFormats]: + /// sorted by max-width plus max-height, ascending, + /// and filtered to those with `animated: true`. + List get sortedAnimatedThumbnailFormats; + /// A digest of [serverThumbnailFormats]: + /// sorted by max-width plus max-height, ascending, + /// and filtered to those with `animated: false`. + List get sortedStillThumbnailFormats; //|////////////////////////////////////////////////////////////// // Realm settings. @@ -171,6 +179,10 @@ mixin ProxyRealmStore on RealmStore { @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; @@ -238,6 +250,10 @@ class RealmStoreImpl extends HasUserGroupStore with RealmStore { 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, @@ -385,6 +401,12 @@ class RealmStoreImpl extends HasUserGroupStore with RealmStore { @override final List serverThumbnailFormats; + @override + List get sortedAnimatedThumbnailFormats => _sortedAnimatedThumbnailFormats; + final List _sortedAnimatedThumbnailFormats; + @override + List get sortedStillThumbnailFormats => _sortedStillThumbnailFormats; + final List _sortedStillThumbnailFormats; @override final bool realmAllowMessageEditing; @@ -446,6 +468,23 @@ 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-width plus max-height, ascending. + static int _compareThumbnailFormats(ThumbnailFormat a, ThumbnailFormat b) { + final aValue = a.maxWidth + a.maxHeight; + final bValue = b.maxWidth + b.maxHeight; + return aValue.compareTo(bValue); + } + void handleCustomProfileFieldsEvent(CustomProfileFieldsEvent event) { customProfileFields = _sortCustomProfileFields(event.fields); } diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index 2b6388c700..797ce20e35 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -637,11 +637,13 @@ class MessageImagePreview extends StatelessWidget { // TODO image hover animation final srcUrl = node.srcUrl; - final thumbnailUrl = node.thumbnail?.defaultFormatSrc; + final thumbnailLocator = node.thumbnail; final store = PerAccountStoreWidget.of(context); final resolvedSrcUrl = store.tryResolveUrl(srcUrl); - final resolvedThumbnailUrl = thumbnailUrl == null - ? null : store.tryResolveUrl(thumbnailUrl.toString()); + final resolvedThumbnailUrl = thumbnailLocator?.resolve(context, + width: MessageMediaContainer.width, + height: MessageMediaContainer.height, + animationMode: ImageAnimationMode.animateConditionally); // TODO if src fails to parse, show an explicit "broken image" @@ -736,6 +738,12 @@ class MessageMediaContainer extends StatelessWidget { final void Function()? onTap; final Widget? child; + /// The container's width, in logical pixels. + static const width = 150.0; + + /// The container's height, in logical pixels. + static const height = 100.0; + @override Widget build(BuildContext context) { return GestureDetector( @@ -751,8 +759,8 @@ class MessageMediaContainer extends StatelessWidget { child: Padding( padding: const EdgeInsets.all(1), child: SizedBox( - height: 100, - width: 150, + width: width, + height: height, child: child)))))); } } diff --git a/lib/widgets/image.dart b/lib/widgets/image.dart index 972d9b82b7..fe7c309d76 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,65 @@ enum ImageAnimationMode { } } } + +extension ImageThumbnailLocatorExtension on ImageThumbnailLocator { + /// Chooses an appropriate format from [PerAccountStore.serverThumbnailFormats], + /// represented as an absolute URL. + /// + /// [height] and [width] are in logical pixels. + /// + /// Requires an ancestor [PerAccountStoreWidget]. + /// + /// The returned URL may not work + /// without adding authentication credentials to the request. + Uri? resolve( + BuildContext context, { + required double width, + required double height, + required ImageAnimationMode animationMode, + }) { + final devicePixelRatio = MediaQuery.devicePixelRatioOf(context); + final widthPhysicalPx = (width * devicePixelRatio).ceil(); + final heightPhysicalPx = (height * devicePixelRatio).ceil(); + + final store = PerAccountStoreWidget.of(context); + + ThumbnailFormat? bestCandidate; + if (animated && animationMode.shouldAnimate(context)) { + bestCandidate ??= _bestFormatOf(store.sortedAnimatedThumbnailFormats, + width: widthPhysicalPx, height: heightPhysicalPx); + } + bestCandidate ??= _bestFormatOf(store.sortedStillThumbnailFormats, + width: widthPhysicalPx, height: heightPhysicalPx); + + if (bestCandidate == null) { + // There are no known thumbnail formats applicable; and yet we have this + // thumbnail locator, indicating this image has a thumbnail. + // Unlikely but seems theoretically possible: + // maybe thumbnailing isn't used now, for new uploads, + // but it was used in the past, including for this image. + // Anyway, fall back to the format encoded in this locator's path. + return store.realmUrl.resolveUri(defaultFormatSrc); + } + + final defaultFormatPath = defaultFormatSrc.path; + final lastSlashIndexInPath = defaultFormatPath.lastIndexOf('/'); + final adjustedPath = + '${defaultFormatPath.substring(0, lastSlashIndexInPath)}/${bestCandidate.name}'; + + return store.realmUrl.resolveUri(defaultFormatSrc.replace(path: adjustedPath)); + } + + 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/widgets/image_test.dart b/test/widgets/image_test.dart index 706793b951..37e7a32641 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,136 @@ void main() { check(tester.takeException()).isA(); }); }); + + group('ImageThumbnailLocator.resolve', () { + late PerAccountStore store; + + Future prepare(WidgetTester tester, {List? formats}) async { + addTearDown(testBinding.reset); + + formats ??= [ + 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: '500x850.jpg', + maxWidth: 500, maxHeight: 850, animated: false, format: 'jpg'), + ThumbnailFormat(name: '500x850-anim.jpg', + maxWidth: 500, maxHeight: 850, 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: formats, + )); + + 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, + double width, + double height, + bool animateIfSupported, + String expected, { + required bool animated, + }) { + final locator = ImageThumbnailLocator( + defaultFormatSrc: Uri.parse('/user_uploads/thumbnail/1/2/a/pic.jpg/840x560.webp'), + animated: animated, + ); + + final context = tester.element(find.byType(Placeholder)); + final result = locator.resolve(context, + width: width, height: height, + animationMode: animateIfSupported + ? ImageAnimationMode.animateAlways + : ImageAnimationMode.animateNever); + check(result.toString()).equals(expected); + } + + testWidgets('animated version does not exist', (tester) async { + tester.view.devicePixelRatio = 2.0; + addTearDown(tester.view.resetDevicePixelRatio); + + await prepare(tester); + + doCheck(tester, 200, 200, false, animated: false, + 'https://chat.example/user_uploads/thumbnail/1/2/a/pic.jpg/500x850.jpg'); + doCheck(tester, 250, 425, true, animated: false, + 'https://chat.example/user_uploads/thumbnail/1/2/a/pic.jpg/500x850.jpg'); + doCheck(tester, 250, 425, false, animated: false, + 'https://chat.example/user_uploads/thumbnail/1/2/a/pic.jpg/500x850.jpg'); + // Different output from previous because it's is in physical pixels. + doCheck(tester, 300, 250, true, animated: false, + 'https://chat.example/user_uploads/thumbnail/1/2/a/pic.jpg/840x560.webp'); + doCheck(tester, 300, 250, false, animated: false, + 'https://chat.example/user_uploads/thumbnail/1/2/a/pic.jpg/840x560.webp'); + doCheck(tester, 750, 1000, false, animated: false, + 'https://chat.example/user_uploads/thumbnail/1/2/a/pic.jpg/1000x2000.png'); + }); + + testWidgets('animated version exists', (tester) async { + tester.view.devicePixelRatio = 2.0; + addTearDown(tester.view.resetDevicePixelRatio); + + await prepare(tester); + + doCheck(tester, 200, 200, false, animated: true, + 'https://chat.example/user_uploads/thumbnail/1/2/a/pic.jpg/500x850.jpg'); + doCheck(tester, 250, 425, true, animated: true, + 'https://chat.example/user_uploads/thumbnail/1/2/a/pic.jpg/500x850-anim.jpg'); + doCheck(tester, 250, 425, false, animated: true, + 'https://chat.example/user_uploads/thumbnail/1/2/a/pic.jpg/500x850.jpg'); + doCheck(tester, 750, 1000, false, animated: true, + 'https://chat.example/user_uploads/thumbnail/1/2/a/pic.jpg/1000x2000.png'); + }); + + testWidgets('query and fragment preserved', (tester) async { + tester.view.devicePixelRatio = 2.0; + addTearDown(tester.view.resetDevicePixelRatio); + + await prepare(tester); + + final locator = ImageThumbnailLocator( + defaultFormatSrc: Uri.parse('/user_uploads/thumbnail/1/2/a/pic.jpg/840x560.webp?x=y#abc'), + animated: false); + + final context = tester.element(find.byType(Placeholder)); + final result = locator.resolve(context, + width: 500, height: 500, + animationMode: ImageAnimationMode.animateNever); + check(result.toString()) + .equals('https://chat.example/user_uploads/thumbnail/1/2/a/pic.jpg/1000x1000.webp?x=y#abc'); + }); + + testWidgets('query and fragment preserved, in fallback to default src (store.serverThumbnailFormats empty)', (tester) async { + tester.view.devicePixelRatio = 2.0; + addTearDown(tester.view.resetDevicePixelRatio); + + await prepare(tester, formats: []); + + final locator = ImageThumbnailLocator( + defaultFormatSrc: Uri.parse('/user_uploads/thumbnail/1/2/a/pic.jpg/840x560.webp?x=y#abc'), + animated: false); + + final context = tester.element(find.byType(Placeholder)); + final result = locator.resolve(context, + width: 500, height: 500, + animationMode: ImageAnimationMode.animateNever); + check(result.toString()) + .equals('https://chat.example/user_uploads/thumbnail/1/2/a/pic.jpg/840x560.webp?x=y#abc'); + }); + }); } From a5b2ef0cc6542f38c0b20960a5e2e7d514d2f597 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 11 Dec 2025 15:12:32 -0800 Subject: [PATCH 7/7] image test [nfc]: Explain ImageThumbnailLocator.resolve tests a bit more --- test/widgets/image_test.dart | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/test/widgets/image_test.dart b/test/widgets/image_test.dart index 37e7a32641..5b7b0126ce 100644 --- a/test/widgets/image_test.dart +++ b/test/widgets/image_test.dart @@ -121,19 +121,24 @@ void main() { await prepare(tester); + // Use the smallest format that's big enough. doCheck(tester, 200, 200, false, animated: false, 'https://chat.example/user_uploads/thumbnail/1/2/a/pic.jpg/500x850.jpg'); - doCheck(tester, 250, 425, true, animated: false, - 'https://chat.example/user_uploads/thumbnail/1/2/a/pic.jpg/500x850.jpg'); doCheck(tester, 250, 425, false, animated: false, 'https://chat.example/user_uploads/thumbnail/1/2/a/pic.jpg/500x850.jpg'); - // Different output from previous because it's is in physical pixels. - doCheck(tester, 300, 250, true, animated: false, - 'https://chat.example/user_uploads/thumbnail/1/2/a/pic.jpg/840x560.webp'); + // The format sizes are in physical pixels. + // This test set devicePixelRatio to 2, so 500 is too small for 300px. doCheck(tester, 300, 250, false, animated: false, 'https://chat.example/user_uploads/thumbnail/1/2/a/pic.jpg/840x560.webp'); + // When no format is big enough, use the largest format. doCheck(tester, 750, 1000, false, animated: false, 'https://chat.example/user_uploads/thumbnail/1/2/a/pic.jpg/1000x2000.png'); + + // Given the image lacks an animated version, animationMode is ignored. + doCheck(tester, 250, 425, true, animated: false, + 'https://chat.example/user_uploads/thumbnail/1/2/a/pic.jpg/500x850.jpg'); + doCheck(tester, 300, 250, true, animated: false, + 'https://chat.example/user_uploads/thumbnail/1/2/a/pic.jpg/840x560.webp'); }); testWidgets('animated version exists', (tester) async { @@ -142,10 +147,14 @@ void main() { await prepare(tester); - doCheck(tester, 200, 200, false, animated: true, - 'https://chat.example/user_uploads/thumbnail/1/2/a/pic.jpg/500x850.jpg'); + // Use the smallest format that's big enough, but animated. doCheck(tester, 250, 425, true, animated: true, 'https://chat.example/user_uploads/thumbnail/1/2/a/pic.jpg/500x850-anim.jpg'); + + // When animationMode says not to animate, though, + // the image's animated version is ignored. + doCheck(tester, 200, 200, false, animated: true, + 'https://chat.example/user_uploads/thumbnail/1/2/a/pic.jpg/500x850.jpg'); doCheck(tester, 250, 425, false, animated: true, 'https://chat.example/user_uploads/thumbnail/1/2/a/pic.jpg/500x850.jpg'); doCheck(tester, 750, 1000, false, animated: true,