feat: реакции, так еще ебать и анимированные!!!!!! правда лагает пизда я это потом пофиксю мб как в тг сделаю

This commit is contained in:
Jganenokk
2026-07-10 00:38:03 +07:00
parent 94ca2016ba
commit c81cd91ecd
13 changed files with 602 additions and 176 deletions
+128
View File
@@ -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<String> fallbackReactions = [
'👍',
'❤️',
'🔥',
'🤣',
'😭',
'😍',
];
final Map<int, Animoji> _byId = {};
List<int> _orderedIds = [];
Future<void>? _loading;
bool get isLoaded => _orderedIds.isNotEmpty;
List<Animoji> get animojis =>
_orderedIds.map((id) => _byId[id]).whereType<Animoji>().toList();
List<String> get emojis => animojis.map((a) => a.emoji).toList();
List<Animoji> get quickAnimojis {
final list = animojis;
return list.length <= 6 ? list : list.sublist(0, 6);
}
Future<void> ensureLoaded() {
return _loading ??= _load().catchError((Object e) {
_loading = null;
throw e;
});
}
Future<void> _load() async {
final setIds = <int>[];
final fallbackIds = <int>[];
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 = <int>[];
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<int> _dedup(List<int> ids) {
final seen = <int>{};
final result = <int>[];
for (final id in ids) {
if (seen.add(id)) result.add(id);
}
return result;
}
void _appendIntList(List<int> target, dynamic raw) {
if (raw is! List) return;
for (final e in raw) {
if (e is int) target.add(e);
}
}
Iterable<List<T>> _chunk<T>(List<T> 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);
}
}
}
+104 -1
View File
@@ -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<MessageAttachment>? attachments,
List<Map<String, dynamic>>? editHistory,
Map<String, dynamic>? 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<String, dynamic>? 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<String, dynamic>? 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<String, dynamic>? 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<String, dynamic>? _normalizeReactionInfo(dynamic raw) {
if (raw is! Map) return null;
final rawCounters = raw['counters'];
if (rawCounters is! List) return null;
final counters = <Map<String, dynamic>>[];
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<int>(0, (a, b) => a + (b['count'] as int)),
};
}
Future<void> _persistReaction(
int chatId,
String messageId,
Map<String, dynamic>? info,
) async {
final accountId = await TokenStorage.getActiveAccountId();
if (accountId == null) return;
final existing = await AppDatabase.loadMessage(accountId, chatId, messageId);
if (existing == null) return;
Map<String, dynamic> payloadMap;
final raw = existing['payload'];
if (raw is String && raw.isNotEmpty) {
try {
payloadMap = Map<String, dynamic>.from(jsonDecode(raw) as Map);
} catch (_) {
payloadMap = <String, dynamic>{};
}
} else {
payloadMap = <String, dynamic>{};
}
if (info == null) {
payloadMap.remove('reactionInfo');
} else {
payloadMap['reactionInfo'] = info;
}
final newRow = Map<String, dynamic>.from(existing);
newRow['payload'] = jsonEncode(payloadMap);
await AppDatabase.saveMessages([newRow]);
}
Future<Map<String, dynamic>?> sendButtonCallback({
required int chatId,
required String messageId,
+90 -20
View File
@@ -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<ChatScreen>
}
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<void> _sendReaction(
CachedMessage message,
String emoji,
bool isToggleOff,
Map<String, dynamic>? previous,
) async {
({bool ok, Map<String, dynamic>? 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<String, dynamic>? info) {
final idx = _messages.indexWhere((m) => m.id == messageId);
if (idx == -1) return;
final payload = <String, dynamic>{...?_messages[idx].payload};
if (info == null) {
payload.remove('reactionInfo');
} else {
payload['reactionInfo'] = info;
}
_messages[idx] = _messages[idx].copyWith(payload: payload);
}
Map<String, dynamic>? _applyLocalReaction(
@@ -299,8 +346,9 @@ class _ChatScreenState extends State<ChatScreen>
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<ChatScreen>
_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<ChatScreen>
void _syncReactionNotifiersFromMessages() {
for (final m in _messages) {
if (_reactionNotifiers.containsKey(m.id)) continue;
final info = m.payload?['reactionInfo'];
final value = info is Map ? Map<String, dynamic>.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<String, dynamic>.from(info) : null,
);
}
}
bool _reactionsEqual(Map<String, dynamic>? a, Map<String, dynamic>? 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<ChatScreen>
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<ReactionEmoji> _quickReactionEmojis() {
final quick = animojiModule.quickAnimojis;
if (quick.isEmpty) {
return AnimojiModule.fallbackReactions
.map((e) => ReactionEmoji(emoji: e))
.toList();
}
return quick.map(_toReactionEmoji).toList();
}
List<ReactionEmoji> _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;
@@ -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,
@@ -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<bool> 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<String, _StickerFrames> _entries = {};
final Map<String, Future<_StickerFrames?>> _loading = {};
final Map<String, _LottieFrames> _entries = {};
final Map<String, Future<_LottieFrames?>> _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<bool> isScrolling;
const StickerScrollScope({
const LottieScrollScope({
super.key,
required this.isScrolling,
required super.child,
});
static ValueListenable<bool>? of(BuildContext context) => context
.dependOnInheritedWidgetOfExactType<StickerScrollScope>()
.dependOnInheritedWidgetOfExactType<LottieScrollScope>()
?.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<StickerLottie> createState() => _StickerLottieState();
State<LottiePlayer> createState() => _LottiePlayerState();
}
class _StickerLottieState extends State<StickerLottie>
class _LottiePlayerState extends State<LottiePlayer>
with SingleTickerProviderStateMixin {
final ValueNotifier<int> _frameIndex = ValueNotifier(0);
late final Ticker _ticker;
_StickerFrames? _frames;
_LottieFrames? _frames;
ValueListenable<bool>? _scrollState;
int? _px;
bool _started = false;
@@ -243,19 +243,19 @@ class _StickerLottieState extends State<StickerLottie>
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<StickerLottie>
}
@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<StickerLottie>
@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<StickerLottie>
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<StickerLottie>
);
}
}
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,
);
}
}
+125 -37
View File
@@ -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<String> quickReactions = const ['👍', '❤️', '🔥', '😂', '😮', '😢'],
List<ReactionEmoji> quickReactions = const [
ReactionEmoji(emoji: '👍'),
ReactionEmoji(emoji: '❤️'),
ReactionEmoji(emoji: '🔥'),
ReactionEmoji(emoji: '🤣'),
ReactionEmoji(emoji: '😭'),
ReactionEmoji(emoji: '😍'),
],
Future<List<ReactionEmoji>> 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<String> quickReactions;
final List<ReactionEmoji> quickReactions;
final Future<List<ReactionEmoji>> 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<String> quick,
List<ReactionEmoji> 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<String> quick) {
Widget _buildQuickRow(ColorScheme cs, double cell, List<ReactionEmoji> 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<String> onPick;
const _ReactionEmojiPicker({required this.onPick});
final Future<List<ReactionEmoji>> 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<String> _all = const [];
List<String> _results = const [];
final ValueNotifier<bool> _scrolling = ValueNotifier(false);
List<ReactionEmoji> _all = const [];
List<ReactionEmoji> _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<void> _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<ScrollNotification>(
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(),
);
}
}
+34 -25
View File
@@ -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;
}
-48
View File
@@ -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,
);
}
}
+2 -2
View File
@@ -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,
+4 -5
View File
@@ -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<StickerPanel>
}
Widget _buildContent(ColorScheme cs, int columns, double cell) {
return StickerScrollScope(
return LottieScrollScope(
isScrolling: _scrolling,
child: StickerPeekScope(
child: NotificationListener<ScrollNotification>(
@@ -456,7 +455,7 @@ class _StickerPanelState extends State<StickerPanel>
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,
+2 -2
View File
@@ -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,
+2
View File
@@ -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);
+31
View File
@@ -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<dynamic, dynamic> 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(),
);
}
}