Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions lib/api/model/model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,17 @@ class RealmEmojiItem {
final String emojiCode;
final String name;
final String sourceUrl;

/// The non-animated version, if this is an animated emoji.
///
/// As of 2025-10, this will be missing on animated emoji
/// that were uploaded before Zulip Server 5 when this was added;
/// see https://github.com/zulip/zulip/issues/36339 .
// TODO(server-future) Update dartdoc once all supported servers
// have a fix for https://github.com/zulip/zulip/issues/36339
// i.e. that have run a migration to fill this in for animated emoji.
final String? stillUrl;

final bool deactivated;
final int? authorId;

Expand Down
107 changes: 1 addition & 106 deletions lib/widgets/content.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import 'package:flutter/rendering.dart';
import 'package:html/dom.dart' as dom;
import 'package:intl/intl.dart' as intl;

import '../api/core.dart';
import '../api/model/model.dart';
import '../generated/l10n/zulip_localizations.dart';
import '../model/content.dart';
Expand All @@ -16,6 +15,7 @@ import 'actions.dart';
import 'code_block.dart';
import 'dialog.dart';
import 'icons.dart';
import 'image.dart';
import 'inset_shadow.dart';
import 'katex.dart';
import 'lightbox.dart';
Expand Down Expand Up @@ -1447,111 +1447,6 @@ void _launchUrl(BuildContext context, String urlString) async {
}
}

/// Like [Image.network], but includes [authHeader] if [src] is on-realm.
///
/// Use this to present image content in the ambient realm: avatars, images in
/// messages, etc. Must have a [PerAccountStoreWidget] ancestor.
///
/// If [src] is an on-realm URL (it has the same origin as the ambient
/// [Auth.realmUrl]), then an HTTP request to fetch the image will include the
/// user's [authHeader].
///
/// If [src] is off-realm (e.g., a Gravatar URL), no auth header will be sent.
///
/// The image will be cached according to the cache behavior of [Image.network],
/// which may mean the cache is shared between realms.
class RealmContentNetworkImage extends StatelessWidget {
const RealmContentNetworkImage(
this.src, {
super.key,
this.scale = 1.0,
this.frameBuilder,
this.loadingBuilder,
this.errorBuilder,
this.semanticLabel,
this.excludeFromSemantics = false,
this.width,
this.height,
this.color,
this.opacity,
this.colorBlendMode,
this.fit,
this.alignment = Alignment.center,
this.repeat = ImageRepeat.noRepeat,
this.centerSlice,
this.matchTextDirection = false,
this.gaplessPlayback = false,
this.filterQuality = FilterQuality.low,
this.isAntiAlias = false,
// `headers` skipped
this.cacheWidth,
this.cacheHeight,
});

final Uri src;

final double scale;
final ImageFrameBuilder? frameBuilder;
final ImageLoadingBuilder? loadingBuilder;
final ImageErrorWidgetBuilder? errorBuilder;
final String? semanticLabel;
final bool excludeFromSemantics;
final double? width;
final double? height;
final Color? color;
final Animation<double>? opacity;
final BlendMode? colorBlendMode;
final BoxFit? fit;
final AlignmentGeometry alignment;
final ImageRepeat repeat;
final Rect? centerSlice;
final bool matchTextDirection;
final bool gaplessPlayback;
final FilterQuality filterQuality;
final bool isAntiAlias;
// `headers` skipped
final int? cacheWidth;
final int? cacheHeight;

@override
Widget build(BuildContext context) {
final account = PerAccountStoreWidget.of(context).account;

return Image.network(
src.toString(),

scale: scale,
frameBuilder: frameBuilder,
loadingBuilder: loadingBuilder,
errorBuilder: errorBuilder,
semanticLabel: semanticLabel,
excludeFromSemantics: excludeFromSemantics,
width: width,
height: height,
color: color,
opacity: opacity,
colorBlendMode: colorBlendMode,
fit: fit,
alignment: alignment,
repeat: repeat,
centerSlice: centerSlice,
matchTextDirection: matchTextDirection,
gaplessPlayback: gaplessPlayback,
filterQuality: filterQuality,
isAntiAlias: isAntiAlias,
headers: {
// Only send the auth header to the server `auth` belongs to.
if (src.origin == account.realmUrl.origin) ...authHeader(
email: account.email, apiKey: account.apiKey,
),
...userAgentHeader(),
},
cacheWidth: cacheWidth,
cacheHeight: cacheHeight,
);
}
}

//
// Small helpers.
//
Expand Down
43 changes: 17 additions & 26 deletions lib/widgets/emoji.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import 'package:flutter/widgets.dart';

import '../api/model/model.dart';
import '../model/emoji.dart';
import 'content.dart';
import 'image.dart';

/// A widget showing an emoji.
class EmojiWidget extends StatelessWidget {
Expand All @@ -13,7 +13,7 @@ class EmojiWidget extends StatelessWidget {
required this.squareDimension,
this.squareDimensionScaler = TextScaler.noScaling,
this.imagePlaceholderStyle = EmojiImagePlaceholderStyle.square,
this.neverAnimateImage = false,
this.imageAnimationMode = ImageAnimationMode.animateConditionally,
this.buildCustomTextEmoji,
});

Expand All @@ -35,11 +35,12 @@ class EmojiWidget extends StatelessWidget {

final EmojiImagePlaceholderStyle imagePlaceholderStyle;

/// Whether to show an animated emoji in its still (non-animated) variant
/// only, even if device settings permit animation.
/// Whether to show an animated emoji in its still or animated version.
///
/// Defaults to false.
final bool neverAnimateImage;
/// Ignored except for animated image emoji.
///
/// Defaults to [ImageAnimationMode.animateConditionally].
final ImageAnimationMode imageAnimationMode;

/// An optional callback to specify a custom plain-text emoji style.
///
Expand All @@ -66,7 +67,7 @@ class EmojiWidget extends StatelessWidget {
EmojiImagePlaceholderStyle.nothing => SizedBox.shrink(),
EmojiImagePlaceholderStyle.text => _buildTextEmoji(),
},
neverAnimate: neverAnimateImage),
animationMode: imageAnimationMode),
UnicodeEmojiDisplay() => UnicodeEmojiWidget(
emojiDisplay: emojiDisplay,
size: squareDimension,
Expand Down Expand Up @@ -185,7 +186,7 @@ class ImageEmojiWidget extends StatelessWidget {
required this.size,
this.textScaler = TextScaler.noScaling,
this.errorBuilder,
this.neverAnimate = false,
this.animationMode = ImageAnimationMode.animateConditionally,
});

final ImageEmojiDisplay emojiDisplay;
Expand All @@ -202,30 +203,20 @@ class ImageEmojiWidget extends StatelessWidget {

final ImageErrorWidgetBuilder? errorBuilder;

/// Whether to show an animated emoji in its still (non-animated) variant
/// only, even if device settings permit animation.
/// Whether to show an animated emoji in its still or animated version.
///
/// Ignored for non-animated emoji.
///
/// Defaults to false.
final bool neverAnimate;
/// Defaults to [ImageAnimationMode.animateConditionally].
final ImageAnimationMode animationMode;

@override
Widget build(BuildContext context) {
final doNotAnimate =
neverAnimate
// From reading code, this doesn't actually get set on iOS:
// https://github.com/zulip/zulip-flutter/pull/410#discussion_r1408522293
|| MediaQuery.disableAnimationsOf(context)
|| (defaultTargetPlatform == TargetPlatform.iOS
// TODO(#1924) On iOS 17+ (new in 2023), there's a more closely
// relevant setting than "reduce motion". It's called "auto-play
// animated images"; we should use that once Flutter exposes it.
&& WidgetsBinding.instance.platformDispatcher.accessibilityFeatures.reduceMotion);

final size = textScaler.scale(this.size);

final resolvedUrl = doNotAnimate
? (emojiDisplay.resolvedStillUrl ?? emojiDisplay.resolvedUrl)
: emojiDisplay.resolvedUrl;
final resolvedUrl = animationMode.resolve(context)
? emojiDisplay.resolvedUrl
: (emojiDisplay.resolvedStillUrl ?? emojiDisplay.resolvedUrl);

return RealmContentNetworkImage(
width: size, height: size,
Expand Down
151 changes: 151 additions & 0 deletions lib/widgets/image.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';

import '../api/core.dart';
import 'store.dart';

/// Like [Image.network], but includes [authHeader] if [src] is on-realm.
///
/// Use this to present image content in the ambient realm: avatars, images in
/// messages, etc. Must have a [PerAccountStoreWidget] ancestor.
///
/// If [src] is an on-realm URL (it has the same origin as the ambient
/// [Auth.realmUrl]), then an HTTP request to fetch the image will include the
/// user's [authHeader].
///
/// If [src] is off-realm (e.g., a Gravatar URL), no auth header will be sent.
///
/// The image will be cached according to the cache behavior of [Image.network],
/// which may mean the cache is shared between realms.
class RealmContentNetworkImage extends StatelessWidget {
const RealmContentNetworkImage(
this.src, {
super.key,
this.scale = 1.0,
this.frameBuilder,
this.loadingBuilder,
this.errorBuilder,
this.semanticLabel,
this.excludeFromSemantics = false,
this.width,
this.height,
this.color,
this.opacity,
this.colorBlendMode,
this.fit,
this.alignment = Alignment.center,
this.repeat = ImageRepeat.noRepeat,
this.centerSlice,
this.matchTextDirection = false,
this.gaplessPlayback = false,
this.filterQuality = FilterQuality.low,
this.isAntiAlias = false,
// `headers` skipped
this.cacheWidth,
this.cacheHeight,
});

final Uri src;

final double scale;
final ImageFrameBuilder? frameBuilder;
final ImageLoadingBuilder? loadingBuilder;
final ImageErrorWidgetBuilder? errorBuilder;
final String? semanticLabel;
final bool excludeFromSemantics;
final double? width;
final double? height;
final Color? color;
final Animation<double>? opacity;
final BlendMode? colorBlendMode;
final BoxFit? fit;
final AlignmentGeometry alignment;
final ImageRepeat repeat;
final Rect? centerSlice;
final bool matchTextDirection;
final bool gaplessPlayback;
final FilterQuality filterQuality;
final bool isAntiAlias;
// `headers` skipped
final int? cacheWidth;
final int? cacheHeight;

@override
Widget build(BuildContext context) {
final account = PerAccountStoreWidget.of(context).account;

return Image.network(
src.toString(),

scale: scale,
frameBuilder: frameBuilder,
loadingBuilder: loadingBuilder,
errorBuilder: errorBuilder,
semanticLabel: semanticLabel,
excludeFromSemantics: excludeFromSemantics,
width: width,
height: height,
color: color,
opacity: opacity,
colorBlendMode: colorBlendMode,
fit: fit,
alignment: alignment,
repeat: repeat,
centerSlice: centerSlice,
matchTextDirection: matchTextDirection,
gaplessPlayback: gaplessPlayback,
filterQuality: filterQuality,
isAntiAlias: isAntiAlias,
headers: {
// Only send the auth header to the server `auth` belongs to.
if (src.origin == account.realmUrl.origin) ...authHeader(
email: account.email, apiKey: account.apiKey,
),
...userAgentHeader(),
},
cacheWidth: cacheWidth,
cacheHeight: cacheHeight,
);
}
}

/// Whether to show an animated image in its still or animated version.
///
/// Use [resolve] to evaluate this for the given [BuildContext],
/// which reads device-setting data for [animateConditionally].
enum ImageAnimationMode {
/// Always show the animated version.
animateAlways,

/// Always show the still version.
animateNever,

/// Show the animated version
/// just if animations aren't disabled in device settings.
animateConditionally,
;

/// True if the image should be animated, false if it should be still.
bool resolve(BuildContext context) {
switch (this) {
case animateAlways: return true;
case animateNever: return false;
case animateConditionally:
// From reading code, this doesn't actually get set on iOS:
// https://github.com/zulip/zulip-flutter/pull/410#discussion_r1408522293
if (MediaQuery.disableAnimationsOf(context)) return false;

if (
defaultTargetPlatform == TargetPlatform.iOS
// TODO(#1924) On iOS 17+ (new in 2023), there's a more closely
// relevant setting than "reduce motion". It's called "auto-play
// animated images"; we should use that once Flutter exposes it.
&& WidgetsBinding.instance.platformDispatcher.accessibilityFeatures.reduceMotion
) {
return false;
}

return true;
}
}
}
1 change: 1 addition & 0 deletions lib/widgets/lightbox.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import '../model/binding.dart';
import 'actions.dart';
import 'content.dart';
import 'dialog.dart';
import 'image.dart';
import 'message_list.dart';
import 'page.dart';
import 'store.dart';
Expand Down
Loading