From 10718c7223d179475055e70ab527eb295ccdfdd1 Mon Sep 17 00:00:00 2001 From: Ruhaan Date: Tue, 7 Jul 2026 15:31:23 +0530 Subject: [PATCH 1/6] content: Move dynamic-size `InlineImage` rendering into common helper Moves the aspect ratio based rendering code from `InlineImage` to the common `_Image` helper. Both image previews and inline images should be sized dynamically, based on their aspect ratio. See: https://github.com/zulip/zulip/pull/39474 --- lib/widgets/content.dart | 132 ++++++++++++++++++--------------------- 1 file changed, 60 insertions(+), 72 deletions(-) diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index a6dcc5fe9..5a9d2fe32 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -642,10 +642,12 @@ class MessageImagePreview extends StatelessWidget { @override Widget build(BuildContext context) { - return _Image(node: node, size: MessageMediaContainer.size, - buildContainer: (onTap, child) { - return MessageMediaContainer(onTap: onTap, child: child); - }); + return ColoredBox( + color: ContentTheme.of(context).colorMessageMediaContainerBackground, + child: Padding( + padding: const EdgeInsets.all(1), + child: _Image(node: node, + ambientTextStyle: DefaultTextStyle.of(context).style))); } } @@ -1366,46 +1368,13 @@ class InlineImage extends StatelessWidget { @override Widget build(BuildContext context) { - final devicePixelRatio = MediaQuery.devicePixelRatioOf(context); - - // Follow web's max-height behavior (10em); - // see image_box_em in web/src/postprocess_content.ts. - final maxHeight = ambientTextStyle.fontSize! * 10; - - final imageSize = (node.originalWidth != null && node.originalHeight != null) - ? Size(node.originalWidth!, node.originalHeight!) / devicePixelRatio - // Layout plan when original dimensions are unknown: - // a [MessageMediaContainer]-sized and -colored rectangle. - : MessageMediaContainer.size; - - // (a) Don't let tall, thin images take up too much vertical space, - // which could be annoying to scroll through. And: - // (b) Don't let small images grow to occupy more physical pixels - // than they have data for. - // It looks like web has code for this in web/src/postprocess_content.ts - // but it doesn't account for the device pixel ratio, in 2026-01. - // So in web, small images do get blown up and blurry on modern devices: - // https://chat.zulip.org/#narrow/channel/101-design/topic/Inline.20images.20blown.20up.20and.20blurry/near/2346831 - final size = BoxConstraints(maxHeight: maxHeight) - .constrainSizeAndAttemptToPreserveAspectRatio(imageSize); - - Widget child = _Image(node: node, size: size, - buildContainer: (onTap, child) { - if (onTap == null) return child; - return GestureDetector(onTap: onTap, child: child); - }); - return Padding( // Separate images vertically when they flow onto separate lines. // (3px follows web; see web/styles/rendered_markdown.css.) padding: const EdgeInsets.only(top: 3), - child: ConstrainedBox( - constraints: BoxConstraints.loose(size), - child: AspectRatio( - aspectRatio: size.aspectRatio, - child: ColoredBox( - color: ContentTheme.of(context).colorMessageMediaContainerBackground, - child: child)))); + child: ColoredBox( + color: ContentTheme.of(context).colorMessageMediaContainerBackground, + child: _Image(node: node, ambientTextStyle: ambientTextStyle))); } } @@ -1540,23 +1509,39 @@ class MessageTableCell extends StatelessWidget { } } -typedef _ImageContainerBuilder = Widget Function(VoidCallback? onTap, Widget child); - /// A helper widget to deduplicate much of the logic in common /// between image previews and inline images. class _Image extends StatelessWidget { - const _Image({ - required this.node, - required this.size, - required this.buildContainer, - }); + const _Image({required this.node, required this.ambientTextStyle}); final ImageNode node; - final Size size; - final _ImageContainerBuilder buildContainer; + final TextStyle ambientTextStyle; @override Widget build(BuildContext context) { + final devicePixelRatio = MediaQuery.devicePixelRatioOf(context); + + // Follow web's max-height behavior (10em); + // see image_box_em in web/src/postprocess_content.ts. + final maxHeight = ambientTextStyle.fontSize! * 10; + + final imageSize = (node.originalWidth != null && node.originalHeight != null) + ? Size(node.originalWidth!, node.originalHeight!) / devicePixelRatio + // Layout plan when original dimensions are unknown: + // a [MessageMediaContainer]-sized and -colored rectangle. + : MessageMediaContainer.size; + + // (a) Don't let tall, thin images take up too much vertical space, + // which could be annoying to scroll through. And: + // (b) Don't let small images grow to occupy more physical pixels + // than they have data for. + // It looks like web has code for this in web/src/postprocess_content.ts + // but it doesn't account for the device pixel ratio, in 2026-01. + // So in web, small images do get blown up and blurry on modern devices: + // https://chat.zulip.org/#narrow/channel/101-design/topic/Inline.20images.20blown.20up.20and.20blurry/near/2346831 + final size = BoxConstraints(maxHeight: maxHeight) + .constrainSizeAndAttemptToPreserveAspectRatio(imageSize); + final store = PerAccountStoreWidget.of(context); final message = InheritedMessage.of(context); @@ -1598,31 +1583,34 @@ class _Image extends StatelessWidget { final lightboxDisplayUrl = (node.loading || node.src is ImageNodeSrcThumbnail) ? resolvedOriginalSrc : resolvedSrc; - if (lightboxDisplayUrl == null) { - // TODO(log) - return buildContainer(null, child); - } - - return buildContainer( - () { - Navigator.of(context).push(getImageLightboxRoute( - context: context, - message: message, + if (lightboxDisplayUrl != null) { // TODO(log) if null + child = GestureDetector( + onTap: () { + Navigator.of(context).push(getImageLightboxRoute( + context: context, + message: message, + messageImageContext: context, + src: lightboxDisplayUrl, + thumbnailUrl: node.src is ImageNodeSrcThumbnail + ? node.loading + // (Image thumbnail is loading; don't show hard-coded spinner image + // even if that happens to be a thumbnail URL.) + ? null + : resolvedSrc + : null, + originalWidth: node.originalWidth, + originalHeight: node.originalHeight)); + }, + child: LightboxHero( messageImageContext: context, src: lightboxDisplayUrl, - thumbnailUrl: node.src is ImageNodeSrcThumbnail - ? node.loading - // (Image thumbnail is loading; don't show hard-coded spinner image - // even if that happens to be a thumbnail URL.) - ? null - : resolvedSrc - : null, - originalWidth: node.originalWidth, - originalHeight: node.originalHeight)); - }, - LightboxHero( - messageImageContext: context, - src: lightboxDisplayUrl, + child: child)); + } + + return ConstrainedBox( + constraints: BoxConstraints.loose(size), + child: AspectRatio( + aspectRatio: size.aspectRatio, child: child)); } } From 34340066fc2ad0bc6121b3aaf3ddb81aabb0cc97 Mon Sep 17 00:00:00 2001 From: Ruhaan Date: Tue, 7 Jul 2026 16:06:59 +0530 Subject: [PATCH 2/6] content: Update image preview rendering to match web Image preview galleries now have spacing between images, and image previews have a thicker border, matching web. See: https://github.com/zulip/zulip/blob/0e259db4be1f0f089c533944034d1ce9d57c01d5/web/styles/rendered_markdown.css#L569-L571 --- lib/widgets/content.dart | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index 5a9d2fe32..b4ff14723 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -631,6 +631,8 @@ class MessageImagePreviewList extends StatelessWidget { @override Widget build(BuildContext context) { return Wrap( + spacing: 5, + runSpacing: 5, children: node.imagePreviews.map((node) => MessageImagePreview(node: node)).toList()); } } @@ -642,10 +644,12 @@ class MessageImagePreview extends StatelessWidget { @override Widget build(BuildContext context) { + // The gray background and border are present only for gallery items on web, not + // for standalone inline images; see web/styles/rendered_markdown.css. return ColoredBox( color: ContentTheme.of(context).colorMessageMediaContainerBackground, child: Padding( - padding: const EdgeInsets.all(1), + padding: const EdgeInsets.all(3), child: _Image(node: node, ambientTextStyle: DefaultTextStyle.of(context).style))); } From 0808025fcb2512ec4823cef610e621846ff4992c Mon Sep 17 00:00:00 2001 From: Ruhaan Date: Tue, 7 Jul 2026 16:07:36 +0530 Subject: [PATCH 3/6] content: Update standalone inline image rendering to match web Inline images now use web-matching padding, and the gray background is removed from them. On web that background is present only on gallery items and image previews, not standalone inline images. See: https://github.com/zulip/zulip/blob/0e259db4be1f0f089c533944034d1ce9d57c01d5/web/styles/rendered_markdown.css#L540-L561 --- lib/widgets/content.dart | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index b4ff14723..7729e672e 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -1373,12 +1373,15 @@ class InlineImage extends StatelessWidget { @override Widget build(BuildContext context) { return Padding( - // Separate images vertically when they flow onto separate lines. - // (3px follows web; see web/styles/rendered_markdown.css.) - padding: const EdgeInsets.only(top: 3), - child: ColoredBox( - color: ContentTheme.of(context).colorMessageMediaContainerBackground, - child: _Image(node: node, ambientTextStyle: ambientTextStyle))); + // Separate images vertically when they flow onto separate lines and + // horizontally from adjacent elements. Padding values follow web. + // The conditional removal of margin when an image opens a paragraph in web is + // omitted as the difference is barely noticeable on mobile. + // See web/styles/rendered_markdown.css. + padding: EdgeInsets.symmetric( + vertical: 3, + horizontal: ambientTextStyle.fontSize! * 0.125), + child: _Image(node: node, ambientTextStyle: ambientTextStyle)); } } From a29bf079e05ca04ac6c2571a50bd81ead8afbfc7 Mon Sep 17 00:00:00 2001 From: Ruhaan Date: Thu, 9 Jul 2026 23:01:10 +0530 Subject: [PATCH 4/6] api: Add `RealmMediaPreviewSize` to `InitialSnapshot` --- lib/api/model/initial_snapshot.dart | 22 ++++++++++++++++++++++ lib/api/model/initial_snapshot.g.dart | 13 +++++++++++++ test/example_data.dart | 2 ++ 3 files changed, 37 insertions(+) diff --git a/lib/api/model/initial_snapshot.dart b/lib/api/model/initial_snapshot.dart index 1709d007b..cfa595d3e 100644 --- a/lib/api/model/initial_snapshot.dart +++ b/lib/api/model/initial_snapshot.dart @@ -96,6 +96,9 @@ class InitialSnapshot { /// Search for "realm_wildcard_mention_policy" in https://zulip.com/api/register-queue. final RealmWildcardMentionPolicy realmWildcardMentionPolicy; + @JsonKey(unknownEnumValue: RealmMediaPreviewSize.unknown) + final RealmMediaPreviewSize? realmMediaPreviewSize; // TODO(server-12) + @JsonKey(unknownEnumValue: RealmTopicsPolicy.unknown) final RealmTopicsPolicy? realmTopicsPolicy; // TODO(server-11) @@ -202,6 +205,7 @@ class InitialSnapshot { required this.realmCanDeleteOwnMessageGroup, required this.realmDeleteOwnMessagePolicy, required this.realmWildcardMentionPolicy, + required this.realmMediaPreviewSize, required this.realmTopicsPolicy, required this.realmMandatoryTopics, required this.realmName, @@ -261,6 +265,24 @@ enum RealmDeleteOwnMessagePolicy { int toJson() => apiValue; } +/// A value of [InitialSnapshot.realmMediaPreviewSize]. +/// +/// For docs, search for "realm_media_preview_size" +/// in . +@JsonEnum(valueField: 'apiValue') +enum RealmMediaPreviewSize { + small(apiValue: 100), + medium(apiValue: 150), + large(apiValue: 200), + unknown(apiValue: null); + + const RealmMediaPreviewSize({required this.apiValue}); + + final int? apiValue; + + int? toJson() => apiValue; +} + /// A value of [InitialSnapshot.realmTopicsPolicy]. /// /// For docs, search for "realm_topics_policy" diff --git a/lib/api/model/initial_snapshot.g.dart b/lib/api/model/initial_snapshot.g.dart index 8ecc74a76..749b0a6e0 100644 --- a/lib/api/model/initial_snapshot.g.dart +++ b/lib/api/model/initial_snapshot.g.dart @@ -111,6 +111,11 @@ InitialSnapshot _$InitialSnapshotFromJson( _$RealmWildcardMentionPolicyEnumMap, json['realm_wildcard_mention_policy'], ), + realmMediaPreviewSize: $enumDecodeNullable( + _$RealmMediaPreviewSizeEnumMap, + json['realm_media_preview_size'], + unknownValue: RealmMediaPreviewSize.unknown, + ), realmTopicsPolicy: $enumDecodeNullable( _$RealmTopicsPolicyEnumMap, json['realm_topics_policy'], @@ -210,6 +215,7 @@ Map _$InitialSnapshotToJson( 'realm_can_delete_own_message_group': instance.realmCanDeleteOwnMessageGroup, 'realm_delete_own_message_policy': instance.realmDeleteOwnMessagePolicy, 'realm_wildcard_mention_policy': instance.realmWildcardMentionPolicy, + 'realm_media_preview_size': instance.realmMediaPreviewSize, 'realm_topics_policy': _$RealmTopicsPolicyEnumMap[instance.realmTopicsPolicy], 'realm_mandatory_topics': instance.realmMandatoryTopics, 'realm_name': instance.realmName, @@ -252,6 +258,13 @@ const _$RealmWildcardMentionPolicyEnumMap = { RealmWildcardMentionPolicy.moderators: 7, }; +const _$RealmMediaPreviewSizeEnumMap = { + RealmMediaPreviewSize.small: 100, + RealmMediaPreviewSize.medium: 150, + RealmMediaPreviewSize.large: 200, + RealmMediaPreviewSize.unknown: null, +}; + const _$RealmTopicsPolicyEnumMap = { RealmTopicsPolicy.allowEmptyTopic: 'allow_empty_topic', RealmTopicsPolicy.disableEmptyTopic: 'disable_empty_topic', diff --git a/test/example_data.dart b/test/example_data.dart index 820f00497..646217229 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -1437,6 +1437,7 @@ InitialSnapshot initialSnapshot({ GroupSettingValue? realmCanDeleteOwnMessageGroup, RealmDeleteOwnMessagePolicy? realmDeleteOwnMessagePolicy, RealmWildcardMentionPolicy? realmWildcardMentionPolicy, + RealmMediaPreviewSize? realmMediaPreviewSize, RealmTopicsPolicy? realmTopicsPolicy, bool? realmMandatoryTopics, String? realmName, @@ -1503,6 +1504,7 @@ InitialSnapshot initialSnapshot({ realmCanDeleteOwnMessageGroup: realmCanDeleteOwnMessageGroup, realmDeleteOwnMessagePolicy: realmDeleteOwnMessagePolicy, realmWildcardMentionPolicy: realmWildcardMentionPolicy ?? RealmWildcardMentionPolicy.everyone, + realmMediaPreviewSize: realmMediaPreviewSize, // no default; allow `null` to simulate servers without this realmTopicsPolicy: realmTopicsPolicy, realmMandatoryTopics: realmMandatoryTopics ?? true, From eb9e0e7e58de77310063c5386e6cea36ffd9c651 Mon Sep 17 00:00:00 2001 From: Ruhaan Date: Thu, 9 Jul 2026 23:21:07 +0530 Subject: [PATCH 5/6] realm: Add `RealmStore.realmMediaPreviewSize` and `mediaPreviewSizeFactor` --- lib/model/realm.dart | 8 ++++++++ test/model/realm_test.dart | 20 ++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/lib/model/realm.dart b/lib/model/realm.dart index cffb3095f..b42f0b0b2 100644 --- a/lib/model/realm.dart +++ b/lib/model/realm.dart @@ -58,6 +58,9 @@ mixin RealmStore on PerAccountStoreBase, UserGroupStore { GroupSettingValue? get realmCanDeleteAnyMessageGroup; // TODO(server-10) GroupSettingValue? get realmCanDeleteOwnMessageGroup; // TODO(server-10) bool get realmEnableReadReceipts; + /// The scale factor for image and video preview thumbnails in messages. + double get mediaPreviewSizeFactor => (realmMediaPreviewSize?.apiValue ?? 100) / 100; + RealmMediaPreviewSize? get realmMediaPreviewSize; // TODO(server-12) RealmTopicsPolicy? get realmTopicsPolicy; // TODO(server-11) bool? get realmMandatoryTopics; // TODO(server-11) Remove deprecated setting. int get maxFileUploadSizeMib; @@ -198,6 +201,8 @@ mixin ProxyRealmStore on RealmStore { @override bool get realmEnableReadReceipts => realmStore.realmEnableReadReceipts; @override + RealmMediaPreviewSize? get realmMediaPreviewSize => realmStore.realmMediaPreviewSize; + @override RealmTopicsPolicy? get realmTopicsPolicy => realmStore.realmTopicsPolicy; @override bool? get realmMandatoryTopics => realmStore.realmMandatoryTopics; @@ -269,6 +274,7 @@ class RealmStoreImpl extends HasUserGroupStore with RealmStore { realmAllowMessageEditing = initialSnapshot.realmAllowMessageEditing, realmCanDeleteAnyMessageGroup = initialSnapshot.realmCanDeleteAnyMessageGroup, realmCanDeleteOwnMessageGroup = initialSnapshot.realmCanDeleteOwnMessageGroup, + realmMediaPreviewSize = initialSnapshot.realmMediaPreviewSize, realmTopicsPolicy = initialSnapshot.realmTopicsPolicy, realmMandatoryTopics = initialSnapshot.realmMandatoryTopics, maxFileUploadSizeMib = initialSnapshot.maxFileUploadSizeMib, @@ -434,6 +440,8 @@ class RealmStoreImpl extends HasUserGroupStore with RealmStore { @override final bool realmEnableReadReceipts; @override + final RealmMediaPreviewSize? realmMediaPreviewSize; + @override final RealmTopicsPolicy? realmTopicsPolicy; @override final bool? realmMandatoryTopics; diff --git a/test/model/realm_test.dart b/test/model/realm_test.dart index 404d67446..eba2f10be 100644 --- a/test/model/realm_test.dart +++ b/test/model/realm_test.dart @@ -1,6 +1,7 @@ import 'package:checks/checks.dart'; import 'package:test/scaffolding.dart'; import 'package:zulip/api/model/events.dart'; +import 'package:zulip/api/model/initial_snapshot.dart'; import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/model/permission.dart'; import 'package:zulip/model/realm.dart'; @@ -40,6 +41,25 @@ void main() { doCheck(eg.t('(no topic)'), eg.t(''), 370); }); + test('mediaPreviewSizeFactor', () { + double factorFor(RealmMediaPreviewSize? size) { + final store = eg.store(initialSnapshot: eg.initialSnapshot( + realmMediaPreviewSize: size)); + return store.mediaPreviewSizeFactor; + } + + for (final size in RealmMediaPreviewSize.values) { + final expected = switch (size) { + RealmMediaPreviewSize.small => 1.0, + RealmMediaPreviewSize.medium => 1.5, + RealmMediaPreviewSize.large => 2.0, + RealmMediaPreviewSize.unknown => 1.0, + }; + check(factorFor(size)).equals(expected); + } + check(factorFor(null)).equals(1.0); + }); + group('selfHasPassedWaitingPeriod', () { final testCases = [ ('2024-11-25T10:00+00:00', DateTime.utc(2024, 11, 25 + 0, 10, 00), false), From aa3ef573206f8e6ba3e3c4d41a32852f593aee76 Mon Sep 17 00:00:00 2001 From: Ruhaan Date: Fri, 10 Jul 2026 02:41:00 +0530 Subject: [PATCH 6/6] content: Size images and video thumbnails according to `realmMediaPreviewSize` Fixes #2177. --- lib/widgets/content.dart | 11 ++++++----- test/widgets/content_test.dart | 26 ++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index 7729e672e..4ea99ddd0 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -725,10 +725,11 @@ class MessageMediaContainer extends StatelessWidget { final Widget? child; /// The container's size, in logical pixels. - static const size = Size(150, 100); + static const baseSize = Size(150, 100); @override Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); return GestureDetector( onTap: onTap, child: UnconstrainedBox( @@ -742,7 +743,7 @@ class MessageMediaContainer extends StatelessWidget { child: Padding( padding: const EdgeInsets.all(1), child: SizedBox.fromSize( - size: size, + size: baseSize * store.mediaPreviewSizeFactor, child: child)))))); } } @@ -1526,17 +1527,18 @@ class _Image extends StatelessWidget { @override Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); final devicePixelRatio = MediaQuery.devicePixelRatioOf(context); // Follow web's max-height behavior (10em); // see image_box_em in web/src/postprocess_content.ts. - final maxHeight = ambientTextStyle.fontSize! * 10; + final maxHeight = ambientTextStyle.fontSize! * 10 * store.mediaPreviewSizeFactor; final imageSize = (node.originalWidth != null && node.originalHeight != null) ? Size(node.originalWidth!, node.originalHeight!) / devicePixelRatio // Layout plan when original dimensions are unknown: // a [MessageMediaContainer]-sized and -colored rectangle. - : MessageMediaContainer.size; + : MessageMediaContainer.baseSize; // (a) Don't let tall, thin images take up too much vertical space, // which could be annoying to scroll through. And: @@ -1549,7 +1551,6 @@ class _Image extends StatelessWidget { final size = BoxConstraints(maxHeight: maxHeight) .constrainSizeAndAttemptToPreserveAspectRatio(imageSize); - final store = PerAccountStoreWidget.of(context); final message = InheritedMessage.of(context); final resolvedSrc = switch (node.src) { diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index 77eaadecb..4eea6cd44 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -702,6 +702,20 @@ void main() { check(images.map((i) => i.src).toList()) .deepEquals(expectedImages.map((n) => otherSrc(n.src))); }); + + testWidgets('scale size by mediaPreviewSizeFactor', (tester) async { + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetDevicePixelRatio); + await prepareContent(tester, + messageContent(ContentExample.imagePreviewSingle.html), + initialSnapshot: eg.initialSnapshot( + realmMediaPreviewSize: RealmMediaPreviewSize.large), + wrapWithPerAccountStoreWidget: true); + // The image is tall enough to use the max height: 10em, + // with 1em = [kBaseFontSize]. Size gets doubled by the "large" setting. + check(tester.getSize(find.byType(RealmContentNetworkImage))) + .equals(Size(510, 340)); + }); }); group("MessageInlineVideo", () { @@ -734,6 +748,18 @@ void main() { check(pushedRoutes).single.isA() .fullscreenDialog.isTrue(); // opened lightbox }); + + testWidgets('scale thumbnail size by mediaPreviewSizeFactor', (tester) async { + await prepareContent(tester, + messageContent(ContentExample.videoInline.html), + initialSnapshot: eg.initialSnapshot( + realmMediaPreviewSize: RealmMediaPreviewSize.large), + wrapWithPerAccountStoreWidget: true); + check(tester.getSize(find.descendant( + of: find.byType(MessageMediaContainer), + matching: find.byType(Container))) + ).equals(MessageMediaContainer.baseSize * 2); + }); }); group("MessageEmbedVideo", () {