From c81cd91ecd342b1268f93492870c3fd61ba3ccc1 Mon Sep 17 00:00:00 2001 From: Jganenokk Date: Fri, 10 Jul 2026 00:33:57 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20=D1=80=D0=B5=D0=B0=D0=BA=D1=86=D0=B8?= =?UTF-8?q?=D0=B8,=20=D1=82=D0=B0=D0=BA=20=D0=B5=D1=89=D0=B5=20=D0=B5?= =?UTF-8?q?=D0=B1=D0=B0=D1=82=D1=8C=20=D0=B8=20=D0=B0=D0=BD=D0=B8=D0=BC?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD=D1=8B=D0=B5!!!!!!=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=B4=D0=B0=20=D0=BB=D0=B0=D0=B3=D0=B0?= =?UTF-8?q?=D0=B5=D1=82=20=D0=BF=D0=B8=D0=B7=D0=B4=D0=B0=20=D1=8F=20=D1=8D?= =?UTF-8?q?=D1=82=D0=BE=20=D0=BF=D0=BE=D1=82=D0=BE=D0=BC=20=D0=BF=D0=BE?= =?UTF-8?q?=D1=84=D0=B8=D0=BA=D1=81=D1=8E=20=D0=BC=D0=B1=20=D0=BA=D0=B0?= =?UTF-8?q?=D0=BA=20=D0=B2=20=D1=82=D0=B3=20=D1=81=D0=B4=D0=B5=D0=BB=D0=B0?= =?UTF-8?q?=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/animoji.dart | 128 ++++++++++++++ lib/backend/modules/messages.dart | 105 +++++++++++- lib/frontend/screens/chats/chat_screen.dart | 110 +++++++++--- .../attachment/bubbles/sticker_bubble.dart | 4 +- ...{sticker_lottie.dart => lottie_image.dart} | 112 ++++++++---- .../widgets/message_actions_overlay.dart | 162 ++++++++++++++---- lib/frontend/widgets/message_bubble.dart | 59 ++++--- lib/frontend/widgets/sticker_image.dart | 48 ------ lib/frontend/widgets/sticker_pack_sheet.dart | 4 +- lib/frontend/widgets/sticker_panel.dart | 9 +- lib/frontend/widgets/sticker_peek.dart | 4 +- lib/main.dart | 2 + lib/models/animoji.dart | 31 ++++ 13 files changed, 602 insertions(+), 176 deletions(-) create mode 100644 lib/backend/modules/animoji.dart rename lib/frontend/widgets/{sticker_lottie.dart => lottie_image.dart} (76%) delete mode 100644 lib/frontend/widgets/sticker_image.dart create mode 100644 lib/models/animoji.dart diff --git a/lib/backend/modules/animoji.dart b/lib/backend/modules/animoji.dart new file mode 100644 index 0000000..3afd2ca --- /dev/null +++ b/lib/backend/modules/animoji.dart @@ -0,0 +1,128 @@ +import '../api.dart'; +import '../../core/protocol/opcode_map.dart'; +import '../../core/utils/logger.dart'; +import '../../models/animoji.dart'; + +class AnimojiModule { + final Api _api; + + AnimojiModule(this._api); + + static const List fallbackReactions = [ + '👍', + '❤️', + '🔥', + '🤣', + '😭', + '😍', + ]; + + final Map _byId = {}; + List _orderedIds = []; + Future? _loading; + + bool get isLoaded => _orderedIds.isNotEmpty; + + List get animojis => + _orderedIds.map((id) => _byId[id]).whereType().toList(); + + List get emojis => animojis.map((a) => a.emoji).toList(); + + List get quickAnimojis { + final list = animojis; + return list.length <= 6 ? list : list.sublist(0, 6); + } + + Future ensureLoaded() { + return _loading ??= _load().catchError((Object e) { + _loading = null; + throw e; + }); + } + + Future _load() async { + final setIds = []; + final fallbackIds = []; + + final sync = await _api.sendRequestMap(Opcode.assetsUpdate, { + 'type': 'ANIMOJI_SET', + 'sync': 0, + }); + if (sync != null) { + final sections = sync['sections']; + if (sections is List) { + for (final s in sections) { + if (s is Map) _appendIntList(setIds, s['animojiSetIds']); + } + } + final updates = sync['animojiUpdates']; + if (updates is Map) { + for (final key in updates.keys) { + final id = key is int ? key : int.tryParse(key.toString()); + if (id != null) fallbackIds.add(id); + } + } + } + + final orderedIds = []; + if (setIds.isNotEmpty) { + final setMap = await _api.sendRequestMap(Opcode.assetsGetByIds, { + 'type': 'ANIMOJI_SET', + 'ids': setIds, + }); + if (setMap != null) { + final sets = setMap['animojiSets']; + if (sets is List) { + for (final set in sets) { + if (set is! Map) continue; + _appendIntList(orderedIds, set['animojis']); + _appendIntList(orderedIds, set['animojiIds']); + } + } + } + } + + final ids = _dedup(orderedIds.isNotEmpty ? orderedIds : fallbackIds); + if (ids.isEmpty) return; + + for (final batch in _chunk(ids, 100)) { + final map = await _api.sendRequestMap(Opcode.assetsGetByIds, { + 'type': 'ANIMOJI', + 'ids': batch, + }); + if (map == null) continue; + final list = map['animojis']; + if (list is! List) continue; + for (final e in list) { + if (e is! Map) continue; + final animoji = Animoji.fromMap(e); + if (animoji != null) _byId[animoji.id] = animoji; + } + } + + _orderedIds = ids.where(_byId.containsKey).toList(); + logger.i('Анимодзи: ${_orderedIds.length} доступно для реакций'); + } + + List _dedup(List ids) { + final seen = {}; + final result = []; + for (final id in ids) { + if (seen.add(id)) result.add(id); + } + return result; + } + + void _appendIntList(List target, dynamic raw) { + if (raw is! List) return; + for (final e in raw) { + if (e is int) target.add(e); + } + } + + Iterable> _chunk(List list, int size) sync* { + for (var i = 0; i < list.length; i += size) { + yield list.sublist(i, i + size > list.length ? list.length : i + size); + } + } +} diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index 0ba12fd..6ef434b 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -7,6 +7,7 @@ import '../../core/config/komet_settings.dart'; import '../../core/protocol/opcode_map.dart'; import '../../core/protocol/packet.dart'; import '../../core/storage/app_database.dart'; +import '../../core/storage/token_storage.dart'; import '../../core/utils/logger.dart'; import '../../core/utils/text_format.dart'; import '../../models/attachment.dart'; @@ -393,6 +394,7 @@ class CachedMessage { bool? deleted, List? attachments, List>? editHistory, + Map? payload, }) => CachedMessage( id: id, accountId: accountId, @@ -401,7 +403,7 @@ class CachedMessage { text: text, time: time, status: status ?? this.status, - payload: payload, + payload: payload ?? this.payload, attachments: attachments ?? this.attachments, isControl: isControl, deleted: deleted ?? this.deleted, @@ -1055,6 +1057,107 @@ class MessagesModule { return _api.sendRequestOk(Opcode.msgDelete, payload); } + Future<({bool ok, Map? info})> setReaction( + int chatId, + String messageId, + String emoji, + ) async { + final id = int.tryParse(messageId); + if (id == null) return (ok: false, info: null); + final response = await _api.sendRequest(Opcode.msgReaction, { + 'chatId': chatId, + 'messageId': id, + 'reaction': {'reactionType': 'EMOJI', 'id': emoji}, + }); + return _applyReactionResponse(chatId, messageId, response); + } + + Future<({bool ok, Map? info})> cancelReaction( + int chatId, + String messageId, + ) async { + final id = int.tryParse(messageId); + if (id == null) return (ok: false, info: null); + final response = await _api.sendRequest(Opcode.msgCancelReaction, { + 'chatId': chatId, + 'messageId': id, + }); + return _applyReactionResponse(chatId, messageId, response); + } + + Future<({bool ok, Map? info})> _applyReactionResponse( + int chatId, + String messageId, + Packet response, + ) async { + if (!response.isOk) return (ok: false, info: null); + final payload = response.payload; + final info = payload is Map + ? _normalizeReactionInfo(payload['reactionInfo']) + : null; + try { + await _persistReaction(chatId, messageId, info); + } catch (_) {} + return (ok: true, info: info); + } + + static Map? _normalizeReactionInfo(dynamic raw) { + if (raw is! Map) return null; + final rawCounters = raw['counters']; + if (rawCounters is! List) return null; + final counters = >[]; + for (final c in rawCounters) { + if (c is! Map) continue; + final reaction = c['reaction']?.toString(); + if (reaction == null || reaction.isEmpty) continue; + final count = c['count']; + counters.add({'reaction': reaction, 'count': count is int ? count : 0}); + } + if (counters.isEmpty) return null; + final your = raw['yourReaction']?.toString(); + final total = raw['totalCount']; + return { + 'counters': counters, + if (your != null && your.isNotEmpty) 'yourReaction': your, + 'totalCount': total is int + ? total + : counters.fold(0, (a, b) => a + (b['count'] as int)), + }; + } + + Future _persistReaction( + int chatId, + String messageId, + Map? info, + ) async { + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null) return; + final existing = await AppDatabase.loadMessage(accountId, chatId, messageId); + if (existing == null) return; + + Map payloadMap; + final raw = existing['payload']; + if (raw is String && raw.isNotEmpty) { + try { + payloadMap = Map.from(jsonDecode(raw) as Map); + } catch (_) { + payloadMap = {}; + } + } else { + payloadMap = {}; + } + + if (info == null) { + payloadMap.remove('reactionInfo'); + } else { + payloadMap['reactionInfo'] = info; + } + + final newRow = Map.from(existing); + newRow['payload'] = jsonEncode(payloadMap); + await AppDatabase.saveMessages([newRow]); + } + Future?> sendButtonCallback({ required int chatId, required String messageId, diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 79b65e7..d173e7b 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -26,6 +26,8 @@ import '../../../main.dart'; import '../../../l10n/app_localizations.dart'; import '../../../backend/api.dart'; import '../../../backend/modules/messages.dart'; +import '../../../backend/modules/animoji.dart'; +import '../../../models/animoji.dart'; import '../../../backend/modules/complaints.dart'; import '../../../core/calls/call_controller.dart'; import '../calls/call_screen.dart'; @@ -39,6 +41,7 @@ import '../../../core/storage/draft_store.dart'; import '../../../core/cache/info_cache.dart'; import '../../../core/cache/message_session_cache.dart'; import '../../../core/utils/haptics.dart'; +import '../../../core/utils/emoji_keyword_index.dart'; import '../../../core/utils/logger.dart'; import '../../../core/config/app_cache_extent.dart'; import '../../../core/config/app_colors.dart'; @@ -265,8 +268,52 @@ class _ChatScreenState extends State } void _reactToMessage(CachedMessage message, String emoji) { + if (message.isControl || message.id.startsWith('temp_')) return; final notifier = _reactionNotifierFor(message); - notifier.value = _applyLocalReaction(notifier.value, emoji); + final previous = notifier.value; + final applied = _applyLocalReaction(previous, emoji); + notifier.value = applied; + final isToggleOff = applied == null || applied['yourReaction'] == null; + unawaited(_sendReaction(message, emoji, isToggleOff, previous)); + } + + Future _sendReaction( + CachedMessage message, + String emoji, + bool isToggleOff, + Map? previous, + ) async { + ({bool ok, Map? info}) result; + try { + result = isToggleOff + ? await messagesModule.cancelReaction(widget.chatId, message.id) + : await messagesModule.setReaction(widget.chatId, message.id, emoji); + } catch (_) { + result = (ok: false, info: null); + } + if (!mounted) return; + final notifier = _reactionNotifiers[message.id]; + if (notifier == null) return; + if (!result.ok) { + notifier.value = previous; + Haptics.error(); + showCustomNotification(context, 'Не удалось обновить реакцию'); + return; + } + notifier.value = result.info; + _applyReactionInfoToMessage(message.id, result.info); + } + + void _applyReactionInfoToMessage(String messageId, Map? info) { + final idx = _messages.indexWhere((m) => m.id == messageId); + if (idx == -1) return; + final payload = {...?_messages[idx].payload}; + if (info == null) { + payload.remove('reactionInfo'); + } else { + payload['reactionInfo'] = info; + } + _messages[idx] = _messages[idx].copyWith(payload: payload); } Map? _applyLocalReaction( @@ -299,8 +346,9 @@ class _ChatScreenState extends State final prev = current?['yourReaction']?.toString(); String? your; - if (prev == emoji) { - decrement(emoji); + if (prev != null && + EmojiKeywordIndex.normalize(prev) == EmojiKeywordIndex.normalize(emoji)) { + decrement(prev); your = null; } else { if (prev != null && prev.isNotEmpty) decrement(prev); @@ -417,6 +465,7 @@ class _ChatScreenState extends State _chatController.chatId = widget.chatId; _chatController.isMounted = () => mounted; unawaited(PushService.clearChatNotification(widget.chatId)); + unawaited(animojiModule.ensureLoaded().catchError((_) {})); WidgetsBinding.instance.addObserver(this); chats.chatsChanged.addListener(_onChatsBump); _messageController.addListener(_onTextChanged); @@ -1186,27 +1235,14 @@ class _ChatScreenState extends State void _syncReactionNotifiersFromMessages() { for (final m in _messages) { + if (_reactionNotifiers.containsKey(m.id)) continue; final info = m.payload?['reactionInfo']; - final value = info is Map ? Map.from(info) : null; - final existing = _reactionNotifiers[m.id]; - if (existing == null) { - _reactionNotifiers[m.id] = ValueNotifier(value); - } else if (!_reactionsEqual(existing.value, value)) { - existing.value = value; - } + _reactionNotifiers[m.id] = ValueNotifier( + info is Map ? Map.from(info) : null, + ); } } - bool _reactionsEqual(Map? a, Map? b) { - if (identical(a, b)) return true; - if (a == null || b == null) return false; - if (a.length != b.length) return false; - for (final k in a.keys) { - if (a[k].toString() != b[k].toString()) return false; - } - return true; - } - @override void deactivate() { _saveDraft(); @@ -4216,6 +4252,9 @@ class _ChatScreenState extends State onReplyTap: _jumpToMessage, onAvatarTap: _openSenderProfile, onStickerTap: _openStickerPack, + onReactionTap: message.isControl + ? null + : (emoji) => _reactToMessage(message, emoji), peerName: widget.name, peerAvatarUrl: widget.imageUrl, ); @@ -5639,10 +5678,41 @@ class _SelectableMessageRowState extends State<_SelectableMessageRow> { isPinned: _isPinnedNow(), onReact: widget.onReact, selectedReaction: widget.reactions?.value?['yourReaction']?.toString(), + quickReactions: _quickReactionEmojis(), + loadReactionEmojis: () async { + await animojiModule.ensureLoaded(); + return _animojiReactionEmojis(); + }, onDispose: controller.dispose, ); } + List _quickReactionEmojis() { + final quick = animojiModule.quickAnimojis; + if (quick.isEmpty) { + return AnimojiModule.fallbackReactions + .map((e) => ReactionEmoji(emoji: e)) + .toList(); + } + return quick.map(_toReactionEmoji).toList(); + } + + List _animojiReactionEmojis() { + final list = animojiModule.animojis; + if (list.isEmpty) { + return AnimojiModule.fallbackReactions + .map((e) => ReactionEmoji(emoji: e)) + .toList(); + } + return list.map(_toReactionEmoji).toList(); + } + + ReactionEmoji _toReactionEmoji(Animoji a) => ReactionEmoji( + emoji: a.emoji, + animationUrl: a.lottieUrl, + staticUrl: a.iconUrl, + ); + void _onSecondaryTapDown(TapDownDetails details) { final ctx = _boundaryKey.currentContext; if (ctx == null) return; diff --git a/lib/frontend/widgets/attachment/bubbles/sticker_bubble.dart b/lib/frontend/widgets/attachment/bubbles/sticker_bubble.dart index 1cd38f0..2c15657 100644 --- a/lib/frontend/widgets/attachment/bubbles/sticker_bubble.dart +++ b/lib/frontend/widgets/attachment/bubbles/sticker_bubble.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../../models/attachment.dart'; -import '../../sticker_image.dart'; +import '../../lottie_image.dart'; import 'bubble_context.dart'; class StickerBubble extends StatelessWidget { @@ -25,7 +25,7 @@ class StickerBubble extends StatelessWidget { SizedBox( width: 150, height: 150, - child: StickerImage( + child: LottieImage( url: staticUrl, lottieUrl: lottieUrl, size: 150, diff --git a/lib/frontend/widgets/sticker_lottie.dart b/lib/frontend/widgets/lottie_image.dart similarity index 76% rename from lib/frontend/widgets/sticker_lottie.dart rename to lib/frontend/widgets/lottie_image.dart index 1b685f1..29bc173 100644 --- a/lib/frontend/widgets/sticker_lottie.dart +++ b/lib/frontend/widgets/lottie_image.dart @@ -6,14 +6,14 @@ import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:lottie/lottie.dart'; -class StickerLoadGovernor { - StickerLoadGovernor._() { +class LottieLoadGovernor { + LottieLoadGovernor._() { _budgetMs = _resolveBudgetMs(); _avgMs = _budgetMs; SchedulerBinding.instance.addTimingsCallback(_onTimings); } - static final StickerLoadGovernor instance = StickerLoadGovernor._(); + static final LottieLoadGovernor instance = LottieLoadGovernor._(); final ValueNotifier throttled = ValueNotifier(false); double _budgetMs = 1000 / 60; @@ -43,7 +43,7 @@ class StickerLoadGovernor { } } -class _StickerFrames { +class _LottieFrames { final LottieDrawable drawable; final int frameCount; final Duration duration; @@ -54,7 +54,7 @@ class _StickerFrames { int lastUsed = 0; int active = 0; - _StickerFrames({ + _LottieFrames({ required this.drawable, required this.frameCount, required this.duration, @@ -69,7 +69,7 @@ class _StickerFrames { } final last = _lastImage; - if (last != null && StickerLoadGovernor.instance.throttled.value) { + if (last != null && LottieLoadGovernor.instance.throttled.value) { return last; } @@ -90,7 +90,7 @@ class _StickerFrames { _lastImage = image; final added = pxSize * pxSize * 4; bytes += added; - _StickerFrameCache.instance._onBytesAdded(added); + _LottieFrameCache.instance._onBytesAdded(added); return image; } @@ -104,21 +104,21 @@ class _StickerFrames { } } -class _StickerFrameCache { - _StickerFrameCache._(); - static final _StickerFrameCache instance = _StickerFrameCache._(); +class _LottieFrameCache { + _LottieFrameCache._(); + static final _LottieFrameCache instance = _LottieFrameCache._(); static const int _maxBytes = 384 * 1024 * 1024; static const double _fps = 30; - final Map _entries = {}; - final Map> _loading = {}; + final Map _entries = {}; + final Map> _loading = {}; int _totalBytes = 0; int _clock = 0; int _tick() => ++_clock; - Future<_StickerFrames?> acquire(String url, int pxSize) async { + Future<_LottieFrames?> acquire(String url, int pxSize) async { final key = '$url@$pxSize'; final cached = _entries[key]; if (cached != null) { @@ -146,13 +146,13 @@ class _StickerFrameCache { return entry; } - void release(_StickerFrames frames) { + void release(_LottieFrames frames) { if (frames.active > 0) frames.active--; frames.lastUsed = _tick(); _evictIfNeeded(); } - Future<_StickerFrames?> _load(String url, int pxSize, String key) async { + Future<_LottieFrames?> _load(String url, int pxSize, String key) async { try { final composition = await NetworkLottie( url, @@ -161,7 +161,7 @@ class _StickerFrameCache { final durationMs = composition.duration.inMilliseconds; var frameCount = (durationMs / 1000 * _fps).round(); frameCount = frameCount.clamp(1, 120); - final entry = _StickerFrames( + final entry = _LottieFrames( drawable: LottieDrawable(composition), frameCount: frameCount, duration: durationMs <= 0 @@ -195,31 +195,31 @@ class _StickerFrameCache { } } -class StickerScrollScope extends InheritedWidget { +class LottieScrollScope extends InheritedWidget { final ValueListenable isScrolling; - const StickerScrollScope({ + const LottieScrollScope({ super.key, required this.isScrolling, required super.child, }); static ValueListenable? of(BuildContext context) => context - .dependOnInheritedWidgetOfExactType() + .dependOnInheritedWidgetOfExactType() ?.isScrolling; @override - bool updateShouldNotify(StickerScrollScope oldWidget) => + bool updateShouldNotify(LottieScrollScope oldWidget) => !identical(oldWidget.isScrolling, isScrolling); } -class StickerLottie extends StatefulWidget { +class LottiePlayer extends StatefulWidget { final String lottieUrl; final String? fallbackUrl; final double? size; final int? memCacheWidth; - const StickerLottie({ + const LottiePlayer({ super.key, required this.lottieUrl, this.fallbackUrl, @@ -228,14 +228,14 @@ class StickerLottie extends StatefulWidget { }); @override - State createState() => _StickerLottieState(); + State createState() => _LottiePlayerState(); } -class _StickerLottieState extends State +class _LottiePlayerState extends State with SingleTickerProviderStateMixin { final ValueNotifier _frameIndex = ValueNotifier(0); late final Ticker _ticker; - _StickerFrames? _frames; + _LottieFrames? _frames; ValueListenable? _scrollState; int? _px; bool _started = false; @@ -243,19 +243,19 @@ class _StickerLottieState extends State bool get _isScrolling => _scrollState?.value ?? false; bool get _canLoad => - !_isScrolling && !StickerLoadGovernor.instance.throttled.value; + !_isScrolling && !LottieLoadGovernor.instance.throttled.value; @override void initState() { super.initState(); _ticker = createTicker(_onTick); - StickerLoadGovernor.instance.throttled.addListener(_onGateChanged); + LottieLoadGovernor.instance.throttled.addListener(_onGateChanged); } @override void didChangeDependencies() { super.didChangeDependencies(); - final state = StickerScrollScope.of(context); + final state = LottieScrollScope.of(context); if (!identical(state, _scrollState)) { _scrollState?.removeListener(_onGateChanged); _scrollState = state; @@ -264,12 +264,12 @@ class _StickerLottieState extends State } @override - void didUpdateWidget(StickerLottie oldWidget) { + void didUpdateWidget(LottiePlayer oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.lottieUrl != widget.lottieUrl) { _ticker.stop(); final previous = _frames; - if (previous != null) _StickerFrameCache.instance.release(previous); + if (previous != null) _LottieFrameCache.instance.release(previous); _frames = null; _started = false; _showedFrames = false; @@ -278,11 +278,11 @@ class _StickerLottieState extends State @override void dispose() { - StickerLoadGovernor.instance.throttled.removeListener(_onGateChanged); + LottieLoadGovernor.instance.throttled.removeListener(_onGateChanged); _scrollState?.removeListener(_onGateChanged); _ticker.dispose(); final frames = _frames; - if (frames != null) _StickerFrameCache.instance.release(frames); + if (frames != null) _LottieFrameCache.instance.release(frames); _frameIndex.dispose(); super.dispose(); } @@ -327,10 +327,10 @@ class _StickerLottieState extends State final px = _px; if (_started || px == null) return; _started = true; - _StickerFrameCache.instance.acquire(widget.lottieUrl, px).then((frames) { + _LottieFrameCache.instance.acquire(widget.lottieUrl, px).then((frames) { if (frames == null) return; if (!mounted) { - _StickerFrameCache.instance.release(frames); + _LottieFrameCache.instance.release(frames); return; } setState(() => _frames = frames); @@ -382,3 +382,47 @@ class _StickerLottieState extends State ); } } + +class LottieImage extends StatelessWidget { + final String? url; + final String? lottieUrl; + final double? size; + final int? memCacheWidth; + + const LottieImage({ + super.key, + this.url, + this.lottieUrl, + this.size, + this.memCacheWidth, + }); + + @override + Widget build(BuildContext context) { + if (lottieUrl != null && lottieUrl!.isNotEmpty) { + return LottiePlayer( + lottieUrl: lottieUrl!, + fallbackUrl: url, + size: size, + memCacheWidth: memCacheWidth, + ); + } + return _static(); + } + + Widget _static() { + final src = url ?? ''; + final blank = SizedBox(width: size, height: size); + if (src.isEmpty) return blank; + return CachedNetworkImage( + imageUrl: src, + width: size, + height: size, + fit: BoxFit.contain, + memCacheWidth: memCacheWidth, + fadeInDuration: const Duration(milliseconds: 120), + placeholder: (_, _) => blank, + errorWidget: (_, _, _) => blank, + ); + } +} diff --git a/lib/frontend/widgets/message_actions_overlay.dart b/lib/frontend/widgets/message_actions_overlay.dart index 8cee0f3..2ea8f6d 100644 --- a/lib/frontend/widgets/message_actions_overlay.dart +++ b/lib/frontend/widgets/message_actions_overlay.dart @@ -12,6 +12,19 @@ import '../../core/utils/format.dart'; import '../../core/utils/haptics.dart'; import '../../l10n/app_localizations.dart'; import 'custom_notification.dart'; +import 'lottie_image.dart'; + +class ReactionEmoji { + final String emoji; + final String? animationUrl; + final String? staticUrl; + + const ReactionEmoji({ + required this.emoji, + this.animationUrl, + this.staticUrl, + }); +} enum MessageActionsInteraction { dragAndRelease, click, tap } @@ -90,7 +103,15 @@ void showMessageActions({ bool isPinned = false, void Function(String emoji)? onReact, String? selectedReaction, - List quickReactions = const ['👍', '❤️', '🔥', '😂', '😮', '😢'], + List quickReactions = const [ + ReactionEmoji(emoji: '👍'), + ReactionEmoji(emoji: '❤️'), + ReactionEmoji(emoji: '🔥'), + ReactionEmoji(emoji: '🤣'), + ReactionEmoji(emoji: '😭'), + ReactionEmoji(emoji: '😍'), + ], + Future> Function()? loadReactionEmojis, MessageActionsInteraction interaction = MessageActionsInteraction.dragAndRelease, }) { @@ -119,6 +140,7 @@ void showMessageActions({ onReact: onReact, selectedReaction: selectedReaction, quickReactions: quickReactions, + loadReactionEmojis: loadReactionEmojis, onDismiss: () { if (entry.mounted) entry.remove(); onDispose(); @@ -150,7 +172,8 @@ class _MessageActionsLayer extends StatefulWidget { final bool isPinned; final void Function(String emoji)? onReact; final String? selectedReaction; - final List quickReactions; + final List quickReactions; + final Future> Function()? loadReactionEmojis; const _MessageActionsLayer({ required this.snapshot, @@ -175,6 +198,7 @@ class _MessageActionsLayer extends StatefulWidget { this.onReact, this.selectedReaction, this.quickReactions = const [], + this.loadReactionEmojis, }); @override @@ -846,13 +870,16 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> double radius, double e, double cell, - List quick, + List quick, Size expandedSize, bool pillAbove, ) { final borderRadius = BorderRadius.circular(radius); _pickerCache ??= RepaintBoundary( - child: _ReactionEmojiPicker(onPick: _onReactionPicked), + child: _ReactionEmojiPicker( + onPick: _onReactionPicked, + loadEmojis: widget.loadReactionEmojis, + ), ); return DecoratedBox( @@ -913,13 +940,13 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> ); } - Widget _buildQuickRow(ColorScheme cs, double cell, List quick) { + Widget _buildQuickRow(ColorScheme cs, double cell, List quick) { return Center( child: Row( mainAxisSize: MainAxisSize.min, children: [ const SizedBox(width: 6), - for (final emoji in quick) _quickEmoji(cs, emoji, cell), + for (final reaction in quick) _quickEmoji(cs, reaction, cell), _chevronButton(cs), const SizedBox(width: 4), ], @@ -927,8 +954,8 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> ); } - Widget _quickEmoji(ColorScheme cs, String emoji, double cell) { - final selected = _isSelectedReaction(emoji); + Widget _quickEmoji(ColorScheme cs, ReactionEmoji reaction, double cell) { + final selected = _isSelectedReaction(reaction.emoji); return SizedBox( width: cell, height: cell, @@ -939,9 +966,9 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> shape: const CircleBorder(), child: InkWell( customBorder: const CircleBorder(), - onTap: () => _onReactionPicked(emoji), + onTap: () => _onReactionPicked(reaction.emoji), child: Center( - child: Text(emoji, style: TextStyle(fontSize: cell * 0.6)), + child: _ReactionGlyph(reaction: reaction, size: cell * 0.72), ), ), ), @@ -1380,7 +1407,8 @@ class _Action { class _ReactionEmojiPicker extends StatefulWidget { final ValueChanged onPick; - const _ReactionEmojiPicker({required this.onPick}); + final Future> Function()? loadEmojis; + const _ReactionEmojiPicker({required this.onPick, this.loadEmojis}); @override State<_ReactionEmojiPicker> createState() => _ReactionEmojiPickerState(); @@ -1389,11 +1417,21 @@ class _ReactionEmojiPicker extends StatefulWidget { class _ReactionEmojiPickerState extends State<_ReactionEmojiPicker> { final TextEditingController _searchCtrl = TextEditingController(); final FocusNode _searchFocus = FocusNode(); - List _all = const []; - List _results = const []; + final ValueNotifier _scrolling = ValueNotifier(false); + List _all = const []; + List _results = const []; String _query = ''; bool _loaded = false; + bool _onScrollNotification(ScrollNotification n) { + if (n is ScrollStartNotification || n is ScrollUpdateNotification) { + if (!_scrolling.value) _scrolling.value = true; + } else if (n is ScrollEndNotification) { + if (_scrolling.value) _scrolling.value = false; + } + return false; + } + @override void initState() { super.initState(); @@ -1402,9 +1440,15 @@ class _ReactionEmojiPickerState extends State<_ReactionEmojiPicker> { Future _load() async { await EmojiKeywordIndex.instance.ensureLoaded(); + final loader = widget.loadEmojis; + final emojis = loader != null + ? await loader() + : EmojiKeywordIndex.instance.all + .map((e) => ReactionEmoji(emoji: e)) + .toList(); if (!mounted) return; setState(() { - _all = EmojiKeywordIndex.instance.all; + _all = emojis; _results = _all; _loaded = true; }); @@ -1414,7 +1458,19 @@ class _ReactionEmojiPickerState extends State<_ReactionEmojiPicker> { final q = value.trim(); setState(() { _query = q; - _results = q.isEmpty ? _all : EmojiKeywordIndex.instance.search(q); + if (q.isEmpty) { + _results = _all; + } else { + final matches = EmojiKeywordIndex.instance + .search(q) + .map(EmojiKeywordIndex.normalize) + .toSet(); + _results = _all + .where( + (e) => matches.contains(EmojiKeywordIndex.normalize(e.emoji)), + ) + .toList(); + } }); } @@ -1427,6 +1483,7 @@ class _ReactionEmojiPickerState extends State<_ReactionEmojiPicker> { void dispose() { _searchCtrl.dispose(); _searchFocus.dispose(); + _scrolling.dispose(); super.dispose(); } @@ -1449,25 +1506,31 @@ class _ReactionEmojiPickerState extends State<_ReactionEmojiPicker> { ) : _results.isEmpty ? const SizedBox.shrink() - : GridView.builder( - padding: const EdgeInsets.fromLTRB(8, 2, 8, 10), - keyboardDismissBehavior: - ScrollViewKeyboardDismissBehavior.onDrag, - addAutomaticKeepAlives: false, - gridDelegate: - const SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 40, - mainAxisSpacing: 2, - crossAxisSpacing: 2, - ), - itemCount: _results.length, - itemBuilder: (context, i) { - final emoji = _results[i]; - return _EmojiCell( - emoji: emoji, - onTap: () => widget.onPick(emoji), - ); - }, + : LottieScrollScope( + isScrolling: _scrolling, + child: NotificationListener( + onNotification: _onScrollNotification, + child: GridView.builder( + padding: const EdgeInsets.fromLTRB(8, 2, 8, 10), + keyboardDismissBehavior: + ScrollViewKeyboardDismissBehavior.onDrag, + addAutomaticKeepAlives: false, + gridDelegate: + const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 48, + mainAxisSpacing: 2, + crossAxisSpacing: 2, + ), + itemCount: _results.length, + itemBuilder: (context, i) { + final reaction = _results[i]; + return _EmojiCell( + reaction: reaction, + onTap: () => widget.onPick(reaction.emoji), + ); + }, + ), + ), ), ), ], @@ -1531,16 +1594,41 @@ class _ReactionEmojiPickerState extends State<_ReactionEmojiPicker> { } class _EmojiCell extends StatelessWidget { - final String emoji; + final ReactionEmoji reaction; final VoidCallback onTap; - const _EmojiCell({required this.emoji, required this.onTap}); + const _EmojiCell({required this.reaction, required this.onTap}); @override Widget build(BuildContext context) { return GestureDetector( behavior: HitTestBehavior.opaque, onTap: onTap, - child: Center(child: Text(emoji, style: const TextStyle(fontSize: 22))), + child: Center(child: _ReactionGlyph(reaction: reaction, size: 34)), + ); + } +} + +class _ReactionGlyph extends StatelessWidget { + final ReactionEmoji reaction; + final double size; + const _ReactionGlyph({required this.reaction, required this.size}); + + @override + Widget build(BuildContext context) { + final anim = reaction.animationUrl; + final still = reaction.staticUrl; + final hasAsset = + (anim != null && anim.isNotEmpty) || (still != null && still.isNotEmpty); + if (!hasAsset) { + return Center( + child: Text(reaction.emoji, style: TextStyle(fontSize: size * 0.9)), + ); + } + return LottieImage( + lottieUrl: anim, + url: still, + size: size, + memCacheWidth: (size * 3).round(), ); } } diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index d2811e6..2b65cb9 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -128,6 +128,7 @@ class MessageBubble extends StatelessWidget { final void Function(String messageId)? onReplyTap; final void Function(int senderId)? onAvatarTap; final void Function(StickerAttachment sticker)? onStickerTap; + final void Function(String emoji)? onReactionTap; final String? peerName; final String? peerAvatarUrl; @@ -146,6 +147,7 @@ class MessageBubble extends StatelessWidget { this.onReplyTap, this.onAvatarTap, this.onStickerTap, + this.onReactionTap, this.peerName, this.peerAvatarUrl, }); @@ -855,35 +857,42 @@ class MessageBubble extends StatelessWidget { ); } - chips.add( - Container( - padding: EdgeInsets.fromLTRB(7, 2, avatar != null ? 3 : 7, 2), - decoration: BoxDecoration( - color: isYours - ? cs.primary.withValues(alpha: 0.22) - : _reactionChipBg, - borderRadius: _reactionChipRadius, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text(c.reaction, style: const TextStyle(fontSize: 13)), - if (c.count > 1) ...[ - const SizedBox(width: 3), - Text( - c.count.toString(), - style: TextStyle( - color: isYours ? cs.primary : cs.onSurfaceVariant, - fontSize: 11, - fontWeight: FontWeight.w600, - ), + Widget chip = Container( + padding: EdgeInsets.fromLTRB(7, 2, avatar != null ? 3 : 7, 2), + decoration: BoxDecoration( + color: isYours ? cs.primary.withValues(alpha: 0.22) : _reactionChipBg, + borderRadius: _reactionChipRadius, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(c.reaction, style: const TextStyle(fontSize: 13)), + if (c.count > 1) ...[ + const SizedBox(width: 3), + Text( + c.count.toString(), + style: TextStyle( + color: isYours ? cs.primary : cs.onSurfaceVariant, + fontSize: 11, + fontWeight: FontWeight.w600, ), - ], - if (avatar != null) ...[const SizedBox(width: 5), avatar], + ), ], - ), + if (avatar != null) ...[const SizedBox(width: 5), avatar], + ], ), ); + + final onTap = onReactionTap; + if (onTap != null) { + chip = GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => onTap(c.reaction), + child: chip, + ); + } + + chips.add(chip); } return chips; } diff --git a/lib/frontend/widgets/sticker_image.dart b/lib/frontend/widgets/sticker_image.dart deleted file mode 100644 index 1e9bd55..0000000 --- a/lib/frontend/widgets/sticker_image.dart +++ /dev/null @@ -1,48 +0,0 @@ -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:flutter/material.dart'; - -import 'sticker_lottie.dart'; - -class StickerImage extends StatelessWidget { - final String? url; - final String? lottieUrl; - final double? size; - final int? memCacheWidth; - - const StickerImage({ - super.key, - this.url, - this.lottieUrl, - this.size, - this.memCacheWidth, - }); - - @override - Widget build(BuildContext context) { - if (lottieUrl != null && lottieUrl!.isNotEmpty) { - return StickerLottie( - lottieUrl: lottieUrl!, - fallbackUrl: url, - size: size, - memCacheWidth: memCacheWidth, - ); - } - return _static(); - } - - Widget _static() { - final src = url ?? ''; - final blank = SizedBox(width: size, height: size); - if (src.isEmpty) return blank; - return CachedNetworkImage( - imageUrl: src, - width: size, - height: size, - fit: BoxFit.contain, - memCacheWidth: memCacheWidth, - fadeInDuration: const Duration(milliseconds: 120), - placeholder: (_, _) => blank, - errorWidget: (_, _, _) => blank, - ); - } -} diff --git a/lib/frontend/widgets/sticker_pack_sheet.dart b/lib/frontend/widgets/sticker_pack_sheet.dart index 0c718f2..c727ae1 100644 --- a/lib/frontend/widgets/sticker_pack_sheet.dart +++ b/lib/frontend/widgets/sticker_pack_sheet.dart @@ -8,7 +8,7 @@ import '../../models/sticker.dart'; import '../screens/chats/chat_list_screen.dart'; import 'custom_notification.dart'; import 'small_spinner.dart'; -import 'sticker_image.dart'; +import 'lottie_image.dart'; import 'sticker_peek.dart'; enum _PackAction { forward, copyLink } @@ -278,7 +278,7 @@ class _StickerPackSheetState extends State<_StickerPackSheet> { tags: item.tags, child: Padding( padding: const EdgeInsets.all(6), - child: StickerImage( + child: LottieImage( url: item.url, lottieUrl: item.lottieUrl, memCacheWidth: 220, diff --git a/lib/frontend/widgets/sticker_panel.dart b/lib/frontend/widgets/sticker_panel.dart index 82fbdf1..4671727 100644 --- a/lib/frontend/widgets/sticker_panel.dart +++ b/lib/frontend/widgets/sticker_panel.dart @@ -8,8 +8,7 @@ import '../../core/utils/emoji_keyword_index.dart'; import '../../main.dart' show stickersModule; import '../../models/sticker.dart'; import 'small_spinner.dart'; -import 'sticker_image.dart'; -import 'sticker_lottie.dart'; +import 'lottie_image.dart'; import 'sticker_peek.dart'; class _DragScrollBehavior extends MaterialScrollBehavior { @@ -327,7 +326,7 @@ class _StickerPanelState extends State } Widget _buildContent(ColorScheme cs, int columns, double cell) { - return StickerScrollScope( + return LottieScrollScope( isScrolling: _scrolling, child: StickerPeekScope( child: NotificationListener( @@ -456,7 +455,7 @@ class _StickerPanelState extends State onTap: () => widget.onStickerTap(item), child: Padding( padding: const EdgeInsets.all(6), - child: StickerImage( + child: LottieImage( url: item.url, lottieUrl: item.lottieUrl, memCacheWidth: 220, @@ -572,7 +571,7 @@ class _StickerSectionState extends State<_StickerSection> { onTap: () => widget.onTap(item), child: Padding( padding: const EdgeInsets.all(6), - child: StickerImage( + child: LottieImage( url: item.url, lottieUrl: item.lottieUrl, memCacheWidth: 220, diff --git a/lib/frontend/widgets/sticker_peek.dart b/lib/frontend/widgets/sticker_peek.dart index d744013..99d5330 100644 --- a/lib/frontend/widgets/sticker_peek.dart +++ b/lib/frontend/widgets/sticker_peek.dart @@ -4,7 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import '../../core/utils/haptics.dart'; -import 'sticker_image.dart'; +import 'lottie_image.dart'; class _PeekData { final String? url; @@ -235,7 +235,7 @@ class _PeekOverlay extends StatelessWidget { SizedBox( width: previewSize, height: previewSize, - child: StickerImage( + child: LottieImage( url: d.url, lottieUrl: d.lottieUrl, size: previewSize, diff --git a/lib/main.dart b/lib/main.dart index 19eebf3..c182ec7 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -48,6 +48,7 @@ import 'backend/modules/messages.dart'; import 'backend/modules/outbox.dart'; import 'backend/modules/polls.dart'; import 'backend/modules/stickers.dart'; +import 'backend/modules/animoji.dart'; import 'backend/modules/stories.dart'; import 'backend/modules/self_check.dart'; import 'backend/modules/shared_content.dart'; @@ -78,6 +79,7 @@ final messagesModule = MessagesModule(api); final sharedContentModule = SharedContentModule(api); final pollsModule = PollsModule(api); final stickersModule = StickersModule(api); +final animojiModule = AnimojiModule(api); final webAppModule = WebAppModule(api); final digitalIdModule = DigitalIdModule(webAppModule); final fileUploader = FileUploader(api: api, messages: messagesModule); diff --git a/lib/models/animoji.dart b/lib/models/animoji.dart new file mode 100644 index 0000000..0ace5cf --- /dev/null +++ b/lib/models/animoji.dart @@ -0,0 +1,31 @@ +class Animoji { + final int id; + final String emoji; + final int setId; + final String? iconUrl; + final String? lottieUrl; + final String? lottiePlayUrl; + + const Animoji({ + required this.id, + required this.emoji, + this.setId = 0, + this.iconUrl, + this.lottieUrl, + this.lottiePlayUrl, + }); + + static Animoji? fromMap(Map map) { + final id = map['id']; + final emoji = map['emoji']?.toString(); + if (id is! int || emoji == null || emoji.isEmpty) return null; + return Animoji( + id: id, + emoji: emoji, + setId: map['setId'] is int ? map['setId'] as int : 0, + iconUrl: map['iconUrl']?.toString(), + lottieUrl: map['lottieUrl']?.toString(), + lottiePlayUrl: map['lottiePlayUrl']?.toString(), + ); + } +}