From 8602755a8c24e536de5e6c94ea801bb4bbd531ca Mon Sep 17 00:00:00 2001 From: Jganenokk Date: Mon, 27 Jul 2026 15:13:54 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20animoji=20=D1=80=D0=B5=D0=B0=D0=BA?= =?UTF-8?q?=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/animoji.dart | 16 +- lib/frontend/screens/chats/chat_screen.dart | 32 +++- lib/frontend/widgets/lottie_image.dart | 87 ++++++++- lib/frontend/widgets/message_bubble.dart | 191 +++++++++++++++++++- test/message_reaction_animoji_test.dart | 140 ++++++++++++++ 5 files changed, 452 insertions(+), 14 deletions(-) create mode 100644 test/message_reaction_animoji_test.dart diff --git a/lib/backend/modules/animoji.dart b/lib/backend/modules/animoji.dart index 13b74a8..faab6cb 100644 --- a/lib/backend/modules/animoji.dart +++ b/lib/backend/modules/animoji.dart @@ -2,6 +2,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../api.dart'; import '../../core/protocol/opcode_map.dart'; +import '../../core/utils/emoji_keyword_index.dart'; import '../../core/utils/logger.dart'; import '../../models/animoji.dart'; @@ -23,6 +24,7 @@ class AnimojiModule { static const int _maxRecents = 24; final Map _byId = {}; + final Map _byEmoji = {}; List _orderedIds = []; List _recentIds = []; bool _recentsLoaded = false; @@ -50,7 +52,7 @@ class AnimojiModule { Future noteUsed(Animoji animoji) async { await ensureRecentsLoaded(); - _byId[animoji.id] = animoji; + _remember(animoji); _recentIds.remove(animoji.id); _recentIds.insert(0, animoji.id); if (_recentIds.length > _maxRecents) { @@ -72,6 +74,9 @@ class AnimojiModule { Animoji? cached(int id) => _byId[id]; + Animoji? findByEmoji(String emoji) => + _byEmoji[EmojiKeywordIndex.normalize(emoji)]; + Future fetchById(int id) async { final known = _byId[id]; if (known != null) return known; @@ -84,7 +89,7 @@ class AnimojiModule { for (final e in list) { if (e is! Map) continue; final animoji = Animoji.fromMap(e); - if (animoji != null) _byId[animoji.id] = animoji; + if (animoji != null) _remember(animoji); } return _byId[id]; } @@ -152,7 +157,7 @@ class AnimojiModule { for (final e in list) { if (e is! Map) continue; final animoji = Animoji.fromMap(e); - if (animoji != null) _byId[animoji.id] = animoji; + if (animoji != null) _remember(animoji); } } @@ -160,6 +165,11 @@ class AnimojiModule { logger.i('Анимодзи: ${_orderedIds.length} доступно для реакций'); } + void _remember(Animoji animoji) { + _byId[animoji.id] = animoji; + _byEmoji[EmojiKeywordIndex.normalize(animoji.emoji)] = animoji; + } + List _dedup(List ids) { final seen = {}; final result = []; diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 55b0503..1bf9f1c 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -288,6 +288,9 @@ class _ChatScreenState extends State StreamSubscription? _connSub; final Map?>> _reactionNotifiers = {}; + final ValueNotifier _reactionAnimation = + ValueNotifier(null); + int _reactionAnimationToken = 0; final Map>> _photoUploadProgress = {}; final ValueNotifier _scheduledCount = ValueNotifier(0); @@ -354,6 +357,20 @@ class _ChatScreenState extends State } notifier.value = result.info; _applyReactionInfoToMessage(message.id, result.info); + final appliedReaction = result.info?['yourReaction']?.toString(); + if (!isToggleOff && + appliedReaction != null && + EmojiKeywordIndex.normalize(appliedReaction) == + EmojiKeywordIndex.normalize(emoji)) { + final event = ReactionAnimationEvent( + messageId: message.id, + emoji: appliedReaction, + token: ++_reactionAnimationToken, + ); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _reactionAnimation.value = event; + }); + } } void _applyReactionInfoToMessage( @@ -563,9 +580,10 @@ class _ChatScreenState extends State ); final px = ((44.0 * dpr).clamp(96.0, 512.0) / 32).ceil() * 32; for (final a in animojiModule.quickAnimojis) { - final url = a.lottieUrl; - if (url != null && url.isNotEmpty) { - unawaited(RlottieEngine.instance.prewarm(url, px)); + for (final url in [a.lottieUrl, a.lottiePlayUrl]) { + if (url != null && url.isNotEmpty) { + unawaited(RlottieEngine.instance.prewarm(url, px)); + } } } } @@ -579,7 +597,10 @@ class _ChatScreenState extends State unawaited( animojiModule .ensureLoaded() - .then((_) => _prewarmQuickReactions()) + .then((_) { + _prewarmQuickReactions(); + if (mounted) _bumpMessages(); + }) .catchError((_) {}), ); WidgetsBinding.instance.addObserver(this); @@ -1865,6 +1886,7 @@ class _ChatScreenState extends State n.dispose(); } _reactionNotifiers.clear(); + _reactionAnimation.dispose(); for (final n in _photoUploadProgress.values) { n.dispose(); } @@ -5300,6 +5322,7 @@ class _ChatScreenState extends State reactionsListenable: _reactionNotifierFor( message, ), + reactionAnimation: _reactionAnimation, uploadProgress: _photoProgressFor(message), onReplyTap: (id) => _jumpToMessage(id, fromId: message.id), @@ -7361,4 +7384,3 @@ class _ChatMessageListState extends State<_ChatMessageList> { ); } } - diff --git a/lib/frontend/widgets/lottie_image.dart b/lib/frontend/widgets/lottie_image.dart index 5192010..399bd67 100644 --- a/lib/frontend/widgets/lottie_image.dart +++ b/lib/frontend/widgets/lottie_image.dart @@ -89,6 +89,9 @@ class LottiePlayer extends StatefulWidget { final int? memCacheWidth; final bool shimmer; final bool eager; + final bool animate; + final bool repeat; + final VoidCallback? onCompleted; const LottiePlayer({ super.key, @@ -98,6 +101,9 @@ class LottiePlayer extends StatefulWidget { this.memCacheWidth, this.shimmer = true, this.eager = false, + this.animate = true, + this.repeat = true, + this.onCompleted, }); @override @@ -119,7 +125,9 @@ class _LottiePlayerState extends State int? _px; bool _started = false; bool _showedFrames = false; + bool _completed = false; Timer? _deferTimer; + Timer? _fallbackCompletionTimer; static const Duration _maxLoadDefer = Duration(milliseconds: 700); @@ -174,12 +182,22 @@ class _LottiePlayerState extends State _speed = 1.0; _targetSpeed = _isScrolling ? _slowSpeed : 1.0; _lastElapsedMs = null; + if (_frameIndex.value != 0) _frameIndex.value = 0; + _completed = false; + _fallbackCompletionTimer?.cancel(); + _fallbackCompletionTimer = null; + } else if (oldWidget.animate != widget.animate || + oldWidget.repeat != widget.repeat) { + _resetPlayback(); + final clip = _clip; + if (clip != null) _maybeStartTicker(clip); } } @override void dispose() { _deferTimer?.cancel(); + _fallbackCompletionTimer?.cancel(); LottieLoadGovernor.instance.throttled.removeListener(_onGateChanged); _scrollState?.removeListener(_onGateChanged); _holdState?.removeListener(_onGateChanged); @@ -218,10 +236,22 @@ class _LottiePlayerState extends State _speed = diff.abs() <= step ? _targetSpeed : _speed + step * diff.sign; } - _playheadMs = (_playheadMs + dt * _speed) % periodMs; + final nextPlayhead = _playheadMs + dt * _speed; + if (!widget.repeat && nextPlayhead >= periodMs) { + _playheadMs = periodMs.toDouble(); + if (_frameIndex.value != clip.frameCount - 1) { + _frameIndex.value = clip.frameCount - 1; + } + _completePlayback(); + return; + } + + _playheadMs = widget.repeat ? nextPlayhead % periodMs : nextPlayhead; final t = _playheadMs / periodMs; - final index = - (t * (clip.frameCount - 1)).round().clamp(0, clip.frameCount - 1); + final index = (t * (clip.frameCount - 1)).round().clamp( + 0, + clip.frameCount - 1, + ); if (index != _frameIndex.value) _frameIndex.value = index; } @@ -244,7 +274,11 @@ class _LottiePlayerState extends State } void _maybeStartTicker(RlottieClip clip) { - if (clip.frameCount <= 1) return; + if (!widget.animate || _completed) return; + if (clip.frameCount <= 1) { + if (!widget.repeat) _completePlayback(); + return; + } final lead = clip.frameCount < _leadFrames ? clip.frameCount : _leadFrames; if (clip.ready.value >= lead && !_ticker.isActive) { _lastElapsedMs = null; @@ -252,6 +286,34 @@ class _LottiePlayerState extends State } } + void _resetPlayback() { + _ticker.stop(); + _fallbackCompletionTimer?.cancel(); + _fallbackCompletionTimer = null; + _completed = false; + _playheadMs = 0; + _lastElapsedMs = null; + if (_frameIndex.value != 0) _frameIndex.value = 0; + } + + void _completePlayback() { + if (_completed) return; + _completed = true; + _ticker.stop(); + _fallbackCompletionTimer?.cancel(); + _fallbackCompletionTimer = null; + final callback = widget.onCompleted; + if (callback == null) return; + SchedulerBinding.instance.addPostFrameCallback((_) { + if (mounted) callback(); + }); + } + + void _scheduleFallbackCompletion(Duration duration) { + if (!widget.animate || widget.repeat || _completed) return; + _fallbackCompletionTimer ??= Timer(duration, _completePlayback); + } + void _ensure(double box) { if (_clip != null) return; final dpr = MediaQuery.devicePixelRatioOf(context); @@ -328,6 +390,10 @@ class _LottiePlayerState extends State height: widget.size, fit: BoxFit.contain, frameRate: FrameRate.max, + animate: widget.animate, + repeat: widget.repeat, + onLoaded: (composition) => + _scheduleFallbackCompletion(composition.duration), errorBuilder: (context, _, _) => _staticFallback(widget.size ?? 96.0), ); } @@ -359,6 +425,9 @@ class LottieImage extends StatelessWidget { final int? memCacheWidth; final bool shimmer; final bool eager; + final bool animate; + final bool repeat; + final VoidCallback? onCompleted; const LottieImage({ super.key, @@ -368,6 +437,9 @@ class LottieImage extends StatelessWidget { this.memCacheWidth, this.shimmer = true, this.eager = false, + this.animate = true, + this.repeat = true, + this.onCompleted, }); @override @@ -380,6 +452,9 @@ class LottieImage extends StatelessWidget { memCacheWidth: memCacheWidth, shimmer: shimmer, eager: eager, + animate: animate, + repeat: repeat, + onCompleted: onCompleted, ); } return _static(); @@ -395,7 +470,9 @@ class LottieImage extends StatelessWidget { fit: BoxFit.contain, memCacheWidth: memCacheWidth, fadeInDuration: const Duration(milliseconds: 120), - placeholder: (_, _) => LottieShimmer(size: size), + placeholder: (_, _) => shimmer + ? LottieShimmer(size: size) + : SizedBox(width: size, height: size), errorWidget: (_, _, _) => SizedBox(width: size, height: size), ); } diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index 86bcd1b..eba2e93 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -14,6 +14,7 @@ import '../../core/config/app_bubble_shape.dart'; import '../../core/crypto/message_decryption_cache.dart'; import 'decrypted_text.dart'; import '../../core/utils/bubble_radius.dart'; +import '../../core/utils/emoji_keyword_index.dart'; import '../../core/utils/link_opener.dart'; import '../../core/utils/text_format.dart'; import '../../core/utils/webview_support.dart'; @@ -23,6 +24,7 @@ import 'formatted_message_text.dart'; import 'photo_viewer.dart'; import 'selectable_message_text.dart'; import '../../models/attachment.dart'; +import '../../models/animoji.dart'; import '../../models/reaction_info.dart'; import 'attachment/bubbles/voice_bubble.dart'; import 'attachment/bubbles/bubble_context.dart'; @@ -41,6 +43,20 @@ import 'lottie_image.dart'; final Expando _contentTypeCache = Expando(); +class ReactionAnimationEvent { + final String messageId; + final String emoji; + final int token; + + const ReactionAnimationEvent({ + required this.messageId, + required this.emoji, + required this.token, + }); +} + +typedef ReactionAnimojiResolver = Animoji? Function(String emoji); + class _ZeroIntrinsicWidth extends SingleChildRenderObjectWidget { const _ZeroIntrinsicWidth({required Widget super.child}); @@ -205,6 +221,163 @@ class _RenderReactionsWrap extends RenderWrap { } } +class _ReactionAnimojiGlyph extends StatefulWidget { + final String messageId; + final String emoji; + final Animoji animoji; + final ValueListenable? animation; + + const _ReactionAnimojiGlyph({ + super.key, + required this.messageId, + required this.emoji, + required this.animoji, + this.animation, + }); + + @override + State<_ReactionAnimojiGlyph> createState() => _ReactionAnimojiGlyphState(); +} + +class _ReactionAnimojiGlyphState extends State<_ReactionAnimojiGlyph> { + static const double _size = 18; + static const double _effectSize = _size * 2; + + int? _playingToken; + bool _bodyPlaying = false; + bool _effectPlaying = false; + + @override + void initState() { + super.initState(); + widget.animation?.addListener(_onAnimation); + } + + @override + void didUpdateWidget(_ReactionAnimojiGlyph oldWidget) { + super.didUpdateWidget(oldWidget); + if (!identical(oldWidget.animation, widget.animation)) { + oldWidget.animation?.removeListener(_onAnimation); + widget.animation?.addListener(_onAnimation); + } + if (oldWidget.messageId != widget.messageId || + EmojiKeywordIndex.normalize(oldWidget.emoji) != + EmojiKeywordIndex.normalize(widget.emoji)) { + _playingToken = null; + _bodyPlaying = false; + _effectPlaying = false; + } + } + + @override + void dispose() { + widget.animation?.removeListener(_onAnimation); + super.dispose(); + } + + void _onAnimation() { + final event = widget.animation?.value; + if (event == null || + event.messageId != widget.messageId || + EmojiKeywordIndex.normalize(event.emoji) != + EmojiKeywordIndex.normalize(widget.emoji) || + event.token == _playingToken) { + return; + } + final bodyUrl = widget.animoji.lottieUrl; + final effectUrl = widget.animoji.lottiePlayUrl; + setState(() { + _playingToken = event.token; + _bodyPlaying = bodyUrl != null && bodyUrl.isNotEmpty; + _effectPlaying = effectUrl != null && effectUrl.isNotEmpty; + }); + } + + void _onBodyCompleted() { + if (!mounted) return; + setState(() { + _bodyPlaying = false; + if (!_effectPlaying) _playingToken = null; + }); + } + + void _onEffectCompleted() { + if (!mounted) return; + setState(() { + _effectPlaying = false; + if (!_bodyPlaying) _playingToken = null; + }); + } + + @override + Widget build(BuildContext context) { + final staticUrl = widget.animoji.iconUrl; + final bodyAnimationUrl = widget.animoji.lottieUrl; + final effectAnimationUrl = widget.animoji.lottiePlayUrl; + final Widget body; + if (_bodyPlaying) { + body = LottieImage( + key: ValueKey(('body', _playingToken)), + url: staticUrl, + lottieUrl: bodyAnimationUrl, + size: _size, + memCacheWidth: 64, + shimmer: false, + eager: true, + repeat: false, + onCompleted: _onBodyCompleted, + ); + } else if (staticUrl != null && staticUrl.isNotEmpty) { + body = LottieImage( + url: staticUrl, + size: _size, + memCacheWidth: 64, + shimmer: false, + ); + } else if (bodyAnimationUrl != null && bodyAnimationUrl.isNotEmpty) { + body = LottieImage( + lottieUrl: bodyAnimationUrl, + size: _size, + memCacheWidth: 64, + shimmer: false, + animate: false, + repeat: false, + ); + } else { + body = Text(widget.emoji, style: const TextStyle(fontSize: 13)); + } + + return SizedBox( + width: _size, + height: _size, + child: Stack( + alignment: Alignment.center, + clipBehavior: Clip.none, + children: [ + body, + if (_effectPlaying) + Positioned( + left: -(_effectSize - _size) / 2, + top: -(_effectSize - _size) / 2, + width: _effectSize, + height: _effectSize, + child: LottieImage( + key: ValueKey(('effect', _playingToken)), + lottieUrl: effectAnimationUrl, + size: _effectSize, + memCacheWidth: 128, + shimmer: false, + eager: true, + repeat: false, + onCompleted: _onEffectCompleted, + ), + ), + ], + ), + ); + } +} + class MessageBubble extends StatelessWidget { static final Color _reactionChipBg = Colors.black.withValues(alpha: 0.18); static const BorderRadius _reactionChipRadius = BorderRadius.all( @@ -227,6 +400,8 @@ class MessageBubble extends StatelessWidget { final String? overrideStatus; final ValueListenable? otherReadTime; final ValueListenable?>? reactionsListenable; + final ValueListenable? reactionAnimation; + final ReactionAnimojiResolver? reactionAnimojiResolver; final ValueListenable>? uploadProgress; final void Function(String messageId)? onReplyTap; final void Function(int senderId)? onAvatarTap; @@ -255,6 +430,8 @@ class MessageBubble extends StatelessWidget { this.overrideStatus, this.otherReadTime, this.reactionsListenable, + this.reactionAnimation, + this.reactionAnimojiResolver, this.uploadProgress, this.onReplyTap, this.onAvatarTap, @@ -1159,7 +1336,16 @@ class MessageBubble extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - Text(c.reaction, style: const TextStyle(fontSize: 13)), + if (_resolveReactionAnimoji(c.reaction) case final animoji?) + _ReactionAnimojiGlyph( + key: ValueKey((message.id, c.reaction)), + messageId: message.id, + emoji: c.reaction, + animoji: animoji, + animation: reactionAnimation, + ) + else + Text(c.reaction, style: const TextStyle(fontSize: 13)), if (c.count > 1) ...[ const SizedBox(width: 3), Text( @@ -1190,6 +1376,9 @@ class MessageBubble extends StatelessWidget { return chips; } + Animoji? _resolveReactionAnimoji(String emoji) => + reactionAnimojiResolver?.call(emoji) ?? animojiModule.findByEmoji(emoji); + Widget _reactionAvatar(ColorScheme cs, String? url, String? name) { const double diameter = 17; if (url != null && url.isNotEmpty) { diff --git a/test/message_reaction_animoji_test.dart b/test/message_reaction_animoji_test.dart new file mode 100644 index 0000000..92263b4 --- /dev/null +++ b/test/message_reaction_animoji_test.dart @@ -0,0 +1,140 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/backend/modules/messages.dart'; +import 'package:komet/frontend/widgets/lottie_image.dart'; +import 'package:komet/frontend/widgets/message_bubble.dart'; +import 'package:komet/l10n/app_localizations.dart'; +import 'package:komet/models/animoji.dart'; + +const _reaction = '🔥'; +const _messageId = 'synthetic-message'; + +CachedMessage _message() => const CachedMessage( + id: _messageId, + accountId: 1, + chatId: 2, + senderId: 3, + text: 'Synthetic message', + time: 1000, + payload: { + 'reactionInfo': { + 'counters': [ + {'reaction': _reaction, 'count': 1}, + ], + 'yourReaction': _reaction, + 'totalCount': 1, + }, + }, +); + +Future _pumpBubble( + WidgetTester tester, { + required Animoji animoji, + required ValueListenable animation, +}) async { + await tester.pumpWidget( + MaterialApp( + locale: const Locale('ru'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: MessageBubble( + message: _message(), + isMe: false, + myId: 1, + chatType: 'DIALOG', + reactionAnimation: animation, + reactionAnimojiResolver: (emoji) => + emoji == _reaction ? animoji : null, + ), + ), + ), + ); + await tester.pump(); +} + +void main() { + testWidgets('reaction animoji stays static until its reaction is applied', ( + tester, + ) async { + final animation = ValueNotifier(null); + addTearDown(animation.dispose); + const animoji = Animoji( + id: 1, + emoji: _reaction, + iconUrl: 'https://example.test/reaction.png', + lottieUrl: 'https://example.test/reaction-idle.json', + lottiePlayUrl: 'https://example.test/reaction-play.json', + ); + + await _pumpBubble(tester, animoji: animoji, animation: animation); + + var glyph = tester.widget(find.byType(LottieImage)); + expect(glyph.url, animoji.iconUrl); + expect(glyph.lottieUrl, isNull); + + animation.value = const ReactionAnimationEvent( + messageId: 'different-synthetic-message', + emoji: _reaction, + token: 1, + ); + await tester.pump(); + glyph = tester.widget(find.byType(LottieImage)); + expect(glyph.lottieUrl, isNull); + + animation.value = const ReactionAnimationEvent( + messageId: _messageId, + emoji: _reaction, + token: 2, + ); + await tester.pump(); + final animatedGlyphs = tester + .widgetList(find.byType(LottieImage)) + .toList(); + expect(animatedGlyphs, hasLength(2)); + final body = animatedGlyphs.singleWhere( + (item) => item.lottieUrl == animoji.lottieUrl, + ); + final effect = animatedGlyphs.singleWhere( + (item) => item.lottieUrl == animoji.lottiePlayUrl, + ); + expect(body.size, 18); + expect(body.repeat, isFalse); + expect(effect.size, 36); + expect(effect.repeat, isFalse); + final effectFinder = find.byWidgetPredicate( + (widget) => + widget is LottieImage && + widget.lottieUrl == animoji.lottiePlayUrl, + ); + expect(tester.getSize(effectFinder), const Size.square(36)); + final players = tester + .widgetList(find.byType(LottiePlayer)) + .toList(); + expect(players, hasLength(2)); + expect(players.every((player) => player.animate && !player.repeat), isTrue); + }); + + testWidgets('reaction without an icon holds the first lottie frame', ( + tester, + ) async { + final animation = ValueNotifier(null); + addTearDown(animation.dispose); + const animoji = Animoji( + id: 2, + emoji: _reaction, + lottieUrl: 'https://example.test/reaction-idle.json', + ); + + await _pumpBubble(tester, animoji: animoji, animation: animation); + + final glyph = tester.widget(find.byType(LottieImage)); + expect(glyph.lottieUrl, animoji.lottieUrl); + expect(glyph.animate, isFalse); + expect(glyph.repeat, isFalse); + final player = tester.widget(find.byType(LottiePlayer)); + expect(player.animate, isFalse); + expect(player.repeat, isFalse); + }); +}