feat: animoji реакции

This commit is contained in:
Jganenokk
2026-07-27 15:13:54 +07:00
parent de95fd7c65
commit 8602755a8c
5 changed files with 452 additions and 14 deletions
+13 -3
View File
@@ -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<int, Animoji> _byId = {};
final Map<String, Animoji> _byEmoji = {};
List<int> _orderedIds = [];
List<int> _recentIds = [];
bool _recentsLoaded = false;
@@ -50,7 +52,7 @@ class AnimojiModule {
Future<void> 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<Animoji?> 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<int> _dedup(List<int> ids) {
final seen = <int>{};
final result = <int>[];
+27 -5
View File
@@ -288,6 +288,9 @@ class _ChatScreenState extends State<ChatScreen>
StreamSubscription<SessionState>? _connSub;
final Map<String, ValueNotifier<Map<String, dynamic>?>> _reactionNotifiers =
{};
final ValueNotifier<ReactionAnimationEvent?> _reactionAnimation =
ValueNotifier(null);
int _reactionAnimationToken = 0;
final Map<String, ValueNotifier<List<double>>> _photoUploadProgress = {};
final ValueNotifier<int> _scheduledCount = ValueNotifier(0);
@@ -354,6 +357,20 @@ class _ChatScreenState extends State<ChatScreen>
}
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<ChatScreen>
);
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<ChatScreen>
unawaited(
animojiModule
.ensureLoaded()
.then((_) => _prewarmQuickReactions())
.then((_) {
_prewarmQuickReactions();
if (mounted) _bumpMessages();
})
.catchError((_) {}),
);
WidgetsBinding.instance.addObserver(this);
@@ -1865,6 +1886,7 @@ class _ChatScreenState extends State<ChatScreen>
n.dispose();
}
_reactionNotifiers.clear();
_reactionAnimation.dispose();
for (final n in _photoUploadProgress.values) {
n.dispose();
}
@@ -5300,6 +5322,7 @@ class _ChatScreenState extends State<ChatScreen>
reactionsListenable: _reactionNotifierFor(
message,
),
reactionAnimation: _reactionAnimation,
uploadProgress: _photoProgressFor(message),
onReplyTap: (id) =>
_jumpToMessage(id, fromId: message.id),
@@ -7361,4 +7384,3 @@ class _ChatMessageListState extends State<_ChatMessageList> {
);
}
}
+82 -5
View File
@@ -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<LottiePlayer>
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<LottiePlayer>
_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<LottiePlayer>
_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<LottiePlayer>
}
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<LottiePlayer>
}
}
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<LottiePlayer>
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),
);
}
+190 -1
View File
@@ -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<MessageType> _contentTypeCache = Expando<MessageType>();
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<ReactionAnimationEvent?>? 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<int>? otherReadTime;
final ValueListenable<Map<String, dynamic>?>? reactionsListenable;
final ValueListenable<ReactionAnimationEvent?>? reactionAnimation;
final ReactionAnimojiResolver? reactionAnimojiResolver;
final ValueListenable<List<double>>? 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) {