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/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/lib/widgets/content.dart b/lib/widgets/content.dart index a6dcc5fe9..4ea99ddd0 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,14 @@ 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); - }); + // 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(3), + child: _Image(node: node, + ambientTextStyle: DefaultTextStyle.of(context).style))); } } @@ -719,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( @@ -736,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)))))); } } @@ -1366,46 +1373,16 @@ 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)))); + // 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)); } } @@ -1540,24 +1517,40 @@ 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 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 * 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.baseSize; + + // (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 message = InheritedMessage.of(context); final resolvedSrc = switch (node.src) { @@ -1598,31 +1591,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)); } } 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, 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), 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", () {