feat: animoji реакции
This commit is contained in:
@@ -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>[];
|
||||
|
||||
@@ -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> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user