diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index 67573bb..e5de6c2 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -940,6 +940,7 @@ class AccountModule { ContactCache.clear(); TranscriptionCache.clear(); + ChatsModule.resetForAccountSwitch(); await ContactsModule.primeCacheFromDb(accountId); try { diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index 96387e6..a45a8e9 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -5,12 +5,13 @@ import 'package:flutter/foundation.dart'; import '../../core/protocol/opcode_map.dart'; import '../../core/protocol/packet.dart'; +import '../../core/cache/info_cache.dart'; import '../../core/storage/app_database.dart'; import '../../core/storage/token_storage.dart'; import '../../core/utils/logger.dart'; import '../api.dart'; import 'folders.dart'; -import 'messages.dart' show ContactCache; +import 'messages.dart' show ContactCache, CachedMessage; Map _parseParticipants(dynamic raw) { try { @@ -146,27 +147,317 @@ class CachedChat { }; } +sealed class MessageEvent { + final int chatId; + const MessageEvent(this.chatId); +} + +class MessageAddedEvent extends MessageEvent { + final CachedMessage message; + const MessageAddedEvent(super.chatId, this.message); +} + +class MessageEditedEvent extends MessageEvent { + final CachedMessage message; + const MessageEditedEvent(super.chatId, this.message); +} + +class MessageRemovedEvent extends MessageEvent { + final String messageId; + const MessageRemovedEvent(super.chatId, this.messageId); +} + +class MessageReactionsChangedEvent extends MessageEvent { + final String messageId; + final Map? reactionInfo; + const MessageReactionsChangedEvent(super.chatId, this.messageId, this.reactionInfo); +} + class ChatsModule { static const int muteOff = 0; static const int muteForever = -1; + /// Sentinel в `lastMsgText` когда последнее сообщение в чате удалено, + /// а кеша истории нет — UI должен отрисовать курсивную плашку. + static const String lastMsgPlaceholder = '__komet_lastmsg_placeholder__'; + + static final _messageEventsController = + StreamController.broadcast(); + static Stream get messageEvents => + _messageEventsController.stream; + static final ValueNotifier chatsChanged = ValueNotifier(0); static void _bump() => chatsChanged.value = chatsChanged.value + 1; static StreamSubscription? _globalPushSub; + static StreamSubscription? _globalStateSub; + + static final Set _dirtyChats = {}; + static final Set _knownChats = {}; + + static bool isChatDirty(int chatId) => _dirtyChats.contains(chatId); + static void markChatClean(int chatId) => _dirtyChats.remove(chatId); + static void markChatDirty(int chatId) => _dirtyChats.add(chatId); + static void registerKnownChat(int chatId) => _knownChats.add(chatId); static void attachGlobalPushHandlers(Api api) { _globalPushSub?.cancel(); + _globalStateSub?.cancel(); _globalPushSub = api.pushStream.listen(_handleGlobalPush); + _globalStateSub = api.stateStream.listen(_handleSessionState); + if (api.state != SessionState.online) { + _markAllKnownChatsDirty(); + } + } + + static Future _handleSessionState(SessionState state) async { + if (state == SessionState.disconnected) { + ContactInfoFetch.clear(); + PresenceFetch.clear(); + ChatInfoFetch.clear(); + await _markAllKnownChatsDirty(); + } + } + + static void resetForAccountSwitch() { + _dirtyChats.clear(); + _knownChats.clear(); + ContactInfoFetch.clear(); + PresenceFetch.clear(); + ChatInfoFetch.clear(); + } + + static Future _markAllKnownChatsDirty() async { + if (_knownChats.isEmpty) { + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null) return; + final rows = await AppDatabase.loadChats(accountId); + for (final row in rows) { + final id = row['id']; + if (id is int) _knownChats.add(id); + } + } + _dirtyChats.addAll(_knownChats); } static Future _handleGlobalPush(Packet packet) async { switch (packet.opcode) { + case Opcode.notifMessage: + await _handleNotifMessage(packet); case Opcode.notifMark: await _handleNotifMark(packet); + case Opcode.notifMsgReactionsChanged: + await _handleNotifMsgReactionsChanged(packet); } } + static Future _handleNotifMessage(Packet packet) async { + final payload = packet.payload; + if (payload is! Map) return; + final chatId = payload['chatId']; + if (chatId is! int) return; + final msg = payload['message']; + if (msg is! Map) return; + + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null) return; + + final senderId = msg['sender'] as int?; + final msgIdStr = msg['id']?.toString(); + final msgIdInt = (msg['id'] is int) + ? msg['id'] as int + : int.tryParse(msgIdStr ?? ''); + final msgTime = msg['time'] as int?; + final msgText = msg['text'] as String?; + final status = msg['status'] as String?; + final unread = payload['unread'] as int?; + + var rows = await AppDatabase.loadChat(accountId, chatId); + if (rows.isEmpty) { + try { + final chatInfo = await ChatInfoFetch.get(chatId); + if (chatInfo != null) { + await cacheServerChat(chatInfo, accountId); + } + } catch (e) { + logger.w('notifMessage: fetch info for unknown chat $chatId failed: $e'); + return; + } + rows = await AppDatabase.loadChat(accountId, chatId); + if (rows.isEmpty) return; + } + + if (status == 'REMOVED' && msgIdStr != null) { + await AppDatabase.deleteMessage(accountId, chatId, msgIdStr); + final cachedChat = CachedChat.fromDbRow(rows.first); + if (cachedChat.lastMsgId == msgIdInt) { + await _reconcileLastMessage(accountId, chatId, rows.first, unread: unread); + } else if (unread != null) { + final newRow = Map.from(rows.first); + newRow['unread_count'] = unread; + await AppDatabase.saveChats([newRow]); + } + _messageEventsController.add(MessageRemovedEvent(chatId, msgIdStr)); + _bump(); + return; + } + + CachedMessage? emittedMessage; + if (status == 'EDITED' && msgIdStr != null) { + final existing = await AppDatabase.loadMessage(accountId, chatId, msgIdStr); + if (existing != null) { + Map mergedPayload; + final existingPayloadRaw = existing['payload']; + if (existingPayloadRaw is String && existingPayloadRaw.isNotEmpty) { + try { + mergedPayload = Map.from( + jsonDecode(existingPayloadRaw) as Map, + ); + } catch (_) { + mergedPayload = Map.from(msg); + } + } else { + mergedPayload = Map.from(msg); + } + for (final entry in msg.entries) { + if (entry.key == 'reactionInfo') continue; + mergedPayload[entry.key.toString()] = entry.value; + } + final newRow = Map.from(existing); + newRow['text'] = msgText; + newRow['status'] = status; + newRow['payload'] = jsonEncode(mergedPayload); + await AppDatabase.saveMessages([newRow]); + emittedMessage = CachedMessage.fromDbRow(newRow); + _messageEventsController.add(MessageEditedEvent(chatId, emittedMessage)); + } + } else if (msgIdStr != null) { + final existing = await AppDatabase.loadMessage(accountId, chatId, msgIdStr); + if (existing == null) { + final cached = CachedMessage.fromPushPayload(accountId, chatId, msg); + await AppDatabase.saveMessages([cached.toDbRow()]); + emittedMessage = cached; + _messageEventsController.add(MessageAddedEvent(chatId, cached)); + } + } + + final cached = CachedChat.fromDbRow(rows.first); + final isStaleLast = status != 'REMOVED' && + msgIdInt != null && + cached.lastMsgId == msgIdInt && + status != 'EDITED'; + if (isStaleLast) { + _bump(); + return; + } + + final newRow = Map.from(rows.first); + if (status != 'REMOVED') { + if (msgIdInt != null) newRow['last_msg_id'] = msgIdInt; + if (msgTime != null) { + newRow['last_msg_time'] = msgTime; + if (status != 'EDITED') { + newRow['last_event_time'] = msgTime; + } + } + newRow['last_msg_text'] = msgText; + if (senderId != null) newRow['last_msg_sender'] = senderId; + } + if (unread != null) newRow['unread_count'] = unread; + + await AppDatabase.saveChats([newRow]); + _bump(); + } + + static Future _reconcileLastMessage( + int accountId, + int chatId, + Map chatRow, { + int? unread, + }) async { + final latest = await AppDatabase.loadMessages(accountId, chatId, limit: 1); + final newRow = Map.from(chatRow); + if (latest.isNotEmpty) { + final m = latest.first; + newRow['last_msg_id'] = int.tryParse(m['id']?.toString() ?? ''); + newRow['last_msg_text'] = m['text']; + newRow['last_msg_time'] = m['time']; + newRow['last_msg_sender'] = m['sender_id']; + } else { + newRow['last_msg_id'] = null; + newRow['last_msg_text'] = lastMsgPlaceholder; + newRow['last_msg_sender'] = null; + } + if (unread != null) newRow['unread_count'] = unread; + await AppDatabase.saveChats([newRow]); + } + + /// Вызывается после успешного фетча истории чата — + /// если в превью был placeholder, заменяем его на актуальное + /// последнее сообщение из кеша. + static Future reconcileLastMessageIfPlaceholder( + int accountId, + int chatId, + ) async { + final rows = await AppDatabase.loadChat(accountId, chatId); + if (rows.isEmpty) return; + final chat = CachedChat.fromDbRow(rows.first); + if (chat.lastMsgText != lastMsgPlaceholder) return; + await _reconcileLastMessage(accountId, chatId, rows.first); + _bump(); + } + + static Future _handleNotifMsgReactionsChanged(Packet packet) async { + final payload = packet.payload; + if (payload is! Map) return; + final chatId = payload['chatId']; + if (chatId is! int) return; + final messageId = payload['messageId']?.toString(); + if (messageId == null || messageId.isEmpty) return; + + 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 = {}; + } + + final counters = payload['counters']; + final totalCount = payload['totalCount']; + final reactionInfo = {}; + final prev = payloadMap['reactionInfo']; + if (prev is Map && prev['yourReaction'] != null) { + reactionInfo['yourReaction'] = prev['yourReaction']; + } + if (counters is List) reactionInfo['counters'] = counters; + if (totalCount is int) reactionInfo['totalCount'] = totalCount; + if (reactionInfo['counters'] == null || (counters is List && counters.isEmpty)) { + payloadMap.remove('reactionInfo'); + } else { + payloadMap['reactionInfo'] = reactionInfo; + } + + final newRow = Map.from(existing); + newRow['payload'] = jsonEncode(payloadMap); + await AppDatabase.saveMessages([newRow]); + final emitted = payloadMap['reactionInfo'] as Map?; + _messageEventsController.add( + MessageReactionsChangedEvent(chatId, messageId, emitted), + ); + _bump(); + } + static Future _handleNotifMark(Packet packet) async { final payload = packet.payload; if (payload is! Map) return; @@ -288,6 +579,7 @@ class ChatsModule { logger.w('cacheServerChat: parse returned null for chat=${chat['id']}'); return null; } + _knownChats.add(parsed.id); final ex = existing[parsed.id]; if (ex != null && _sameContent(ex, parsed)) { return parsed; @@ -383,7 +675,11 @@ class ChatsModule { static Future> getChats(int accountId) async { try { final rows = await AppDatabase.loadChats(accountId); - return rows.map(CachedChat.fromDbRow).toList(); + final chats = rows.map(CachedChat.fromDbRow).toList(); + for (final c in chats) { + _knownChats.add(c.id); + } + return chats; } catch (e) { logger.e("Ошибка при получении чатов: $e"); return []; diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index f74c9e0..7a29566 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -245,6 +245,29 @@ class CachedMessage { 'status': status, 'payload': payload != null ? jsonEncode(payload) : null, }; + + static CachedMessage fromPushPayload(int accountId, int chatId, Map msg) { + List? attachments; + final attaches = msg['attaches']; + if (attaches is List && attaches.isNotEmpty) { + attachments = attaches + .whereType() + .map((a) => + MessageAttachment.fromMap(Map.from(a))) + .toList(); + } + return CachedMessage( + id: msg['id']?.toString() ?? '', + accountId: accountId, + chatId: chatId, + senderId: msg['sender'] as int? ?? 0, + text: msg['text'] as String?, + time: (msg['time'] as int?) ?? DateTime.now().millisecondsSinceEpoch, + status: (msg['status'] as String?) ?? 'sent', + payload: Map.from(msg), + attachments: attachments, + ); + } } class MessagesModule { diff --git a/lib/core/cache/info_cache.dart b/lib/core/cache/info_cache.dart new file mode 100644 index 0000000..8fdc292 --- /dev/null +++ b/lib/core/cache/info_cache.dart @@ -0,0 +1,226 @@ +import 'dart:async'; + +import '../../backend/api.dart'; +import '../protocol/opcode_map.dart'; + +Api? _api; + +void attachInfoCacheApi(Api api) { + _api = api; +} + +class _Entry { + T? value; + DateTime? fetchedAt; + DateTime? failedAt; + Future? inFlight; +} + +class InfoCache { + final Duration ttl; + final Duration failureBackoff; + final Future Function(int id) fetcher; + final Map> _entries = {}; + + InfoCache({ + required this.ttl, + required this.fetcher, + this.failureBackoff = const Duration(seconds: 10), + }); + + bool _isFresh(_Entry e) { + if (e.fetchedAt == null) return false; + return DateTime.now().difference(e.fetchedAt!) < ttl; + } + + bool _isInFailureBackoff(_Entry e) { + if (e.failedAt == null) return false; + return DateTime.now().difference(e.failedAt!) < failureBackoff; + } + + Future get(int id, {bool forceRefresh = false}) { + final entry = _entries.putIfAbsent(id, () => _Entry()); + + if (!forceRefresh && _isFresh(entry)) { + return Future.value(entry.value); + } + if (!forceRefresh && _isInFailureBackoff(entry)) { + return Future.value(null); + } + if (entry.inFlight != null) return entry.inFlight!; + + final future = _runFetch(entry, id); + entry.inFlight = future; + return future; + } + + Future _runFetch(_Entry entry, int id) async { + try { + final result = await fetcher(id); + entry.value = result; + entry.fetchedAt = DateTime.now(); + entry.failedAt = null; + return result; + } catch (_) { + entry.failedAt = DateTime.now(); + return null; + } finally { + entry.inFlight = null; + } + } + + T? peek(int id) { + final entry = _entries[id]; + if (entry == null || !_isFresh(entry)) return null; + return entry.value; + } + + void invalidate(int id) => _entries.remove(id); + void clear() => _entries.clear(); + + void putValue(int id, T value, {DateTime? at}) { + final entry = _entries.putIfAbsent(id, () => _Entry()); + entry.value = value; + entry.fetchedAt = at ?? DateTime.now(); + entry.failedAt = null; + } + + void markFailed(int id, {DateTime? at}) { + final entry = _entries.putIfAbsent(id, () => _Entry()); + entry.failedAt = at ?? DateTime.now(); + } +} + +class ContactInfoFetch { + static final _cache = InfoCache>( + ttl: const Duration(minutes: 5), + fetcher: _fetch, + ); + + static Future?> get(int id, {bool forceRefresh = false}) => + _cache.get(id, forceRefresh: forceRefresh); + + static Map? peek(int id) => _cache.peek(id); + + static void invalidate(int id) => _cache.invalidate(id); + static void clear() => _cache.clear(); + + static Future?> _fetch(int id) async { + final api = _api; + if (api == null || api.state != SessionState.online) return null; + final resp = await api.sendRequest(Opcode.contactInfo, { + 'contactIds': [id], + }); + final data = resp.payload; + if (data is! Map) return null; + final contacts = data['contacts']; + if (contacts is! List || contacts.isEmpty) return null; + final first = contacts.first; + if (first is! Map) return null; + return Map.from(first); + } +} + +class PresenceFetch { + static final _cache = InfoCache>( + ttl: const Duration(seconds: 60), + fetcher: _fetch, + ); + + static Future?> get(int id, {bool forceRefresh = false}) => + _cache.get(id, forceRefresh: forceRefresh); + + static Map? peek(int id) => _cache.peek(id); + + static void invalidate(int id) => _cache.invalidate(id); + static void clear() => _cache.clear(); + + static Future?> _fetch(int id) async { + final results = await _fetchBatch([id]); + return results[id]; + } + + static Future>> getMany( + List ids, { + bool forceRefresh = false, + }) async { + final result = >{}; + final missing = []; + for (final id in ids) { + if (!forceRefresh) { + final cached = _cache.peek(id); + if (cached != null) { + result[id] = cached; + continue; + } + } + missing.add(id); + } + if (missing.isNotEmpty) { + final fetched = await _fetchBatch(missing); + final now = DateTime.now(); + for (final id in missing) { + final value = fetched[id]; + if (value != null) { + _cache.putValue(id, value, at: now); + result[id] = value; + } else { + _cache.markFailed(id, at: now); + } + } + } + return result; + } + + static Future>> _fetchBatch(List ids) async { + final api = _api; + if (api == null || api.state != SessionState.online || ids.isEmpty) { + return const {}; + } + final resp = await api.sendRequest(Opcode.contactPresence, { + 'contactIds': ids, + }); + final data = resp.payload; + if (data is! Map) return const {}; + final presence = data['presence']; + if (presence is! Map) return const {}; + final out = >{}; + for (final id in ids) { + final entry = presence[id.toString()] ?? presence[id]; + if (entry is Map) { + out[id] = Map.from(entry); + } + } + return out; + } +} + +class ChatInfoFetch { + static final _cache = InfoCache>( + ttl: const Duration(minutes: 5), + fetcher: _fetch, + ); + + static Future?> get(int id, {bool forceRefresh = false}) => + _cache.get(id, forceRefresh: forceRefresh); + + static Map? peek(int id) => _cache.peek(id); + + static void invalidate(int id) => _cache.invalidate(id); + static void clear() => _cache.clear(); + + static Future?> _fetch(int id) async { + final api = _api; + if (api == null || api.state != SessionState.online) return null; + final resp = await api.sendRequest(Opcode.chatInfo, { + 'chatIds': [id], + }); + final data = resp.payload; + if (data is! Map) return null; + final chats = data['chats']; + if (chats is! List || chats.isEmpty) return null; + final first = chats.first; + if (first is! Map) return null; + return Map.from(first); + } +} diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index 8a1d41b..2f62001 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -587,4 +587,33 @@ class AppDatabase { whereArgs: [accountId, chatId], ); } + + static Future?> loadMessage( + int accountId, + int chatId, + String messageId, + ) async { + final db = await _instance; + final rows = await db.query( + 'messages', + where: 'account_id = ? AND chat_id = ? AND id = ?', + whereArgs: [accountId, chatId, messageId], + limit: 1, + ); + if (rows.isEmpty) return null; + return rows.first; + } + + static Future deleteMessage( + int accountId, + int chatId, + String messageId, + ) async { + final db = await _instance; + await db.delete( + 'messages', + where: 'account_id = ? AND chat_id = ? AND id = ?', + whereArgs: [accountId, chatId, messageId], + ); + } } diff --git a/lib/frontend/screens/chats/chat_info_screen.dart b/lib/frontend/screens/chats/chat_info_screen.dart index ef5a480..8965c7a 100644 --- a/lib/frontend/screens/chats/chat_info_screen.dart +++ b/lib/frontend/screens/chats/chat_info_screen.dart @@ -3,9 +3,8 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../backend/modules/messages.dart' show ContactCache; -import '../../../core/protocol/opcode_map.dart'; +import '../../../core/cache/info_cache.dart'; import '../../../core/storage/app_database.dart'; -import '../../../main.dart' as main; class _MemberInfo { final int id; @@ -96,21 +95,13 @@ class _ChatInfoScreenState extends State { final profile = await AppDatabase.loadActiveProfile(); _myId = profile?.id ?? 0; - final packet = await main.api.sendRequest( - Opcode.chatInfo, - {'chatIds': [widget.chatId]}, - ); - if (!packet.isOk || !mounted) { - if (mounted) setState(() => _isLoading = false); + final info = await ChatInfoFetch.get(widget.chatId); + if (!mounted) return; + if (info == null) { + setState(() => _isLoading = false); return; } - - final chats = (packet.payload as Map?)?['chats'] as List?; - if (chats == null || chats.isEmpty) { - if (mounted) setState(() => _isLoading = false); - return; - } - _chatData = Map.from(chats.first as Map); + _chatData = info; if (widget.chatType == 'DIALOG') { final parts = _chatData!['participants'] as Map? ?? {}; @@ -123,32 +114,19 @@ class _ChatInfoScreenState extends State { } if (_otherId != null) { - final cp = await main.api.sendRequest( - Opcode.contactInfo, - {'contactIds': [_otherId]}, - ); - if (cp.isOk) { - final contacts = (cp.payload as Map?)?['contacts'] as List?; - if (contacts != null && contacts.isNotEmpty) { - _contactData = Map.from(contacts.first as Map); - final opts = _contactData!['options']; - _isBot = (opts is List) && opts.contains('BOT'); - } + final contact = await ContactInfoFetch.get(_otherId!); + if (contact != null) { + _contactData = contact; + final opts = _contactData!['options']; + _isBot = (opts is List) && opts.contains('BOT'); } - final pp = await main.api.sendRequest( - Opcode.contactPresence, - {'contactIds': [_otherId]}, - ); - if (pp.isOk) { - final presence = (pp.payload as Map?)?['presence'] as Map?; - final p = presence?[_otherId.toString()] ?? presence?[_otherId]; - if (p is Map) { - _seenTime = p['seen'] as int?; - final st = (p['status'] as int?) ?? 0; - _presenceStatus = st; - _isOnline = st == 1; - } + final presence = await PresenceFetch.get(_otherId!); + if (presence != null) { + _seenTime = presence['seen'] as int?; + final st = (presence['status'] as int?) ?? 0; + _presenceStatus = st; + _isOnline = st == 1; } } } else if (widget.chatType == 'CHAT') { @@ -162,25 +140,9 @@ class _ChatInfoScreenState extends State { if (id != null) memberIds.add(id); } - final Map presenceMap = {}; + Map> presenceMap = {}; if (memberIds.isNotEmpty) { - final pp = await main.api.sendRequest( - Opcode.contactPresence, - {'contactIds': memberIds}, - ); - if (pp.isOk) { - final presence = (pp.payload as Map?)?['presence'] as Map?; - if (presence != null) { - for (final e in presence.entries) { - final id = e.key is int - ? e.key as int - : int.tryParse(e.key.toString()); - if (id != null && e.value is Map) { - presenceMap[id] = e.value as Map; - } - } - } - } + presenceMap = await PresenceFetch.getMany(memberIds); } _onlineCount = 0; diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 655e5f6..624382f 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -1367,10 +1367,15 @@ class _ChatListScreenState extends State // chat.isOfficial covers contacts from the login payload. final isVerified = ContactCache.isOfficial(secondId) || chat.isOfficial; + final isPlaceholder = + chat.lastMsgText == ChatsModule.lastMsgPlaceholder; + final previewText = isPlaceholder + ? 'зайдите в чат для подгрузки' + : (chat.lastMsgTextOneLine ?? ''); return _buildChatItem( chat.id.toString(), name ?? "Пользователь", - chat.lastMsgTextOneLine ?? '', + previewText, _formatTime(chat.lastMsgTime), avatar ?? "", isOnline: chat.isOnline, @@ -1379,20 +1384,25 @@ class _ChatListScreenState extends State isVerified: isVerified, isPinned: isPinned, chatType: "DIALOG", + messageItalic: isPlaceholder, ); } else { - final name = chat.lastMsgSenderId != null + final isPlaceholder = + chat.lastMsgText == ChatsModule.lastMsgPlaceholder; + final sender = chat.lastMsgSenderId != null ? ContactCache.get(chat.lastMsgSenderId!) : null; String fullMsg = ""; - - if (name?.isNotEmpty == true && chat.id != 0) { - fullMsg += "$name: "; - } - - if (chat.lastMsgText?.isNotEmpty == true) { - fullMsg += chat.lastMsgText ?? ""; + if (isPlaceholder) { + fullMsg = 'зайдите в чат для подгрузки'; + } else { + if (sender?.isNotEmpty == true && chat.id != 0) { + fullMsg += "$sender: "; + } + if (chat.lastMsgText?.isNotEmpty == true) { + fullMsg += chat.lastMsgText ?? ""; + } } return _buildChatItem( @@ -1409,6 +1419,7 @@ class _ChatListScreenState extends State isVerified: chat.isOfficial, isPinned: isPinned, chatType: chat.type, + messageItalic: isPlaceholder, ); } }, childCount: totalItems), @@ -2049,6 +2060,7 @@ class _ChatListScreenState extends State bool isVerified = false, bool isPinned = false, String chatType = "CHAT", + bool messageItalic = false, }) { final cs = Theme.of(context).colorScheme; final isSelected = _selectedChats.contains(id); @@ -2230,6 +2242,9 @@ class _ChatListScreenState extends State fontWeight: isTyping ? FontWeight.w500 : FontWeight.w400, + fontStyle: messageItalic + ? FontStyle.italic + : FontStyle.normal, height: 1.2, ), maxLines: 1, diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 6dcd73d..06467c2 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -13,11 +13,11 @@ import 'package:komet/frontend/screens/chats/chat_info_screen.dart'; import 'package:komet/frontend/widgets/custom_notification.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../main.dart'; -import '../../../backend/api.dart'; import '../../../backend/modules/messages.dart'; import '../../../core/protocol/opcode_map.dart'; import '../../../core/protocol/packet.dart'; import '../../../core/storage/app_database.dart'; +import '../../../core/cache/info_cache.dart'; import '../../../core/utils/haptics.dart'; import '../../../core/config/app_cache_extent.dart'; import '../../../core/config/app_message_actions_style.dart'; @@ -89,6 +89,27 @@ class _ChatScreenState extends State final ValueNotifier<_UploadStatus> _uploadStatus = ValueNotifier(const _UploadStatus()); StreamSubscription? _uploadSub; StreamSubscription? _pushSub; + StreamSubscription? _messageEventSub; + final Map?>> _reactionNotifiers = {}; + + ValueNotifier?> _reactionNotifierFor(CachedMessage m) { + final existing = _reactionNotifiers[m.id]; + if (existing != null) return existing; + final info = m.payload?['reactionInfo']; + final notifier = ValueNotifier?>( + info is Map ? Map.from(info) : null, + ); + _reactionNotifiers[m.id] = notifier; + return notifier; + } + + void _pruneReactionNotifiers() { + final liveIds = _messages.map((m) => m.id).toSet(); + final dead = _reactionNotifiers.keys.where((id) => !liveIds.contains(id)).toList(); + for (final id in dead) { + _reactionNotifiers.remove(id)?.dispose(); + } + } final Set _typingUserIds = {}; final Map _typingTimers = {}; int _otherStatus = 0; @@ -130,10 +151,12 @@ class _ChatScreenState extends State _showAttachmentPanel.addListener(_onAttachPanelToggle); _pushSub = api.pushStream .where((p) => - p.opcode == Opcode.notifMessage || p.opcode == Opcode.notifMark || p.opcode == Opcode.notifTyping) .listen(_onIncomingPush); + _messageEventSub = ChatsModule.messageEvents + .where((e) => e.chatId == widget.chatId) + .listen(_onMessageEvent); _floatingDateAnimController = AnimationController( vsync: this, duration: const Duration(milliseconds: 220), @@ -145,9 +168,42 @@ class _ChatScreenState extends State reverseCurve: Curves.easeIn, ); + unawaited(_fastPreloadCache()); WidgetsBinding.instance.addPostFrameCallback(_onFirstFrameRendered); } + Future _fastPreloadCache() async { + final p = await AppDatabase.loadActiveProfile(); + if (!mounted) return; + _myId = p?.id ?? 0; + + ChatsModule.getChat(_myId, widget.chatId).then((value) { + if (mounted && value.isNotEmpty) { + setState(() { + chat = value.first; + }); + _recomputeHeaderStatus(); + } + }).catchError((_) {}); + + final firstRows = await AppDatabase.loadMessages( + _myId, + widget.chatId, + limit: 20, + ); + if (!mounted) return; + if (firstRows.isNotEmpty) { + final first = firstRows.reversed + .map((r) => CachedMessage.fromDbRow(r)) + .toList(); + setState(() { + _messages = first; + _isLoading = false; + _onLoadingFinished(); + }); + } + } + void _onFirstFrameRendered(Duration _) { if (!mounted) return; if (widget.embedded) { @@ -192,37 +248,15 @@ class _ChatScreenState extends State } Future _loadHistory() async { - final activeProfile = await AppDatabase.loadActiveProfile(); - _myId = activeProfile?.id ?? 0; - ChatsModule.getChat(_myId, widget.chatId).then((value) { - if (mounted && value.isNotEmpty) { - setState(() { chat = value.first; }); - _recomputeHeaderStatus(); - } - }).catchError((_) {}); + if (_myId == 0) { + final activeProfile = await AppDatabase.loadActiveProfile(); + if (!mounted) return; + _myId = activeProfile?.id ?? 0; + } if (widget.chatType == 'DIALOG') { unawaited(_loadOtherPresence()); } - - final firstRows = await AppDatabase.loadMessages( - _myId, - widget.chatId, - limit: 20, - ); - if (mounted && firstRows.isNotEmpty) { - final first = firstRows.reversed - .map((r) => CachedMessage.fromDbRow(r)) - .toList(); - setState(() { - _messages = first; - if (api.state == SessionState.online) { - _isLoading = false; - _onLoadingFinished(); - } - }); - } - - unawaited(_loadRemainingHistory()); + await _loadRemainingHistory(); } Future _loadRemainingHistory() async { @@ -235,8 +269,20 @@ class _ChatScreenState extends State _applyMergedMessages(fullRows); } + if (!ChatsModule.isChatDirty(widget.chatId) && fullRows.isNotEmpty) { + if (mounted) { + setState(() { + _isLoading = false; + _onLoadingFinished(); + }); + } + _loadForwardedSenderNames(); + return; + } + try { await messagesModule.fetchHistory(_myId, widget.chatId); + ChatsModule.markChatClean(widget.chatId); final updatedRows = await AppDatabase.loadMessages( _myId, widget.chatId, @@ -245,6 +291,7 @@ class _ChatScreenState extends State if (mounted) { _applyMergedMessages(updatedRows, markLoaded: true); } + unawaited(ChatsModule.reconcileLastMessageIfPlaceholder(_myId, widget.chatId)); _loadForwardedSenderNames(); } catch (e) { debugPrint('Error fetching history: $e'); @@ -280,6 +327,33 @@ class _ChatScreenState extends State _onLoadingFinished(); } }); + if (changed) { + _syncReactionNotifiersFromMessages(); + _pruneReactionNotifiers(); + } + } + + void _syncReactionNotifiersFromMessages() { + for (final m in _messages) { + 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; + } + } + } + + 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; } bool _sameMessage(CachedMessage a, CachedMessage b) { @@ -311,6 +385,11 @@ class _ChatScreenState extends State _showAttachmentPanel.dispose(); _uploadSub?.cancel(); _pushSub?.cancel(); + _messageEventSub?.cancel(); + for (final n in _reactionNotifiers.values) { + n.dispose(); + } + _reactionNotifiers.clear(); for (final t in _typingTimers.values) { t.cancel(); } @@ -358,8 +437,6 @@ class _ChatScreenState extends State void _onIncomingPush(Packet packet) { if (!mounted) return; switch (packet.opcode) { - case Opcode.notifMessage: - _onIncomingMessage(packet); case Opcode.notifMark: _onMessageRead(packet); case Opcode.notifTyping: @@ -367,23 +444,43 @@ class _ChatScreenState extends State } } + void _onMessageEvent(MessageEvent event) { + if (!mounted) return; + switch (event) { + case MessageAddedEvent(:final message): + if (message.senderId == _myId) return; + if (_messages.any((m) => m.id == message.id)) return; + setState(() { + _lastSentId = message.id; + _messages.add(message); + }); + _clearTyping(message.senderId); + Haptics.tap(); + _scrollToBottom(); + case MessageEditedEvent(:final message): + final idx = _messages.indexWhere((m) => m.id == message.id); + if (idx == -1) return; + setState(() => _messages[idx] = message); + case MessageRemovedEvent(:final messageId): + final idx = _messages.indexWhere((m) => m.id == messageId); + if (idx == -1) return; + setState(() => _messages.removeAt(idx)); + _reactionNotifiers.remove(messageId)?.dispose(); + case MessageReactionsChangedEvent(:final messageId, :final reactionInfo): + _reactionNotifiers[messageId]?.value = reactionInfo; + } + } + Future _loadOtherPresence() async { if (_myId == 0) return; final otherId = widget.chatId ^ _myId; if (otherId <= 0) return; try { - final p = await api.sendRequest( - Opcode.contactPresence, - {'contactIds': [otherId]}, - ); - if (!mounted) return; - final presence = (p.payload as Map?)?['presence'] as Map?; - final entry = presence?[otherId.toString()] ?? presence?[otherId]; - if (entry is Map) { - _otherStatus = (entry['status'] as int?) ?? 0; - _otherSeenTime = entry['seen'] as int?; - _recomputeHeaderStatus(); - } + final entry = await PresenceFetch.get(otherId); + if (!mounted || entry == null) return; + _otherStatus = (entry['status'] as int?) ?? 0; + _otherSeenTime = entry['seen'] as int?; + _recomputeHeaderStatus(); } catch (_) {} } @@ -463,53 +560,6 @@ class _ChatScreenState extends State }); } - void _onIncomingMessage(Packet packet) { - if (!mounted) return; - final payload = packet.payload; - if (payload is! Map) return; - final chatId = payload['chatId']; - if (chatId != widget.chatId) return; - final msg = payload['message']; - if (msg is! Map) return; - - final senderId = msg['sender']; - if (senderId is! int) return; - if (senderId == _myId) return; - - final msgId = msg['id']?.toString(); - if (msgId == null || msgId.isEmpty) return; - if (_messages.any((m) => m.id == msgId)) return; - - List? attachments; - final attaches = msg['attaches']; - if (attaches is List && attaches.isNotEmpty) { - attachments = attaches - .whereType() - .map((a) => MessageAttachment.fromMap(Map.from(a))) - .toList(); - } - - final cached = CachedMessage( - id: msgId, - accountId: _myId, - chatId: widget.chatId, - senderId: senderId, - text: msg['text'] as String?, - time: (msg['time'] as int?) ?? DateTime.now().millisecondsSinceEpoch, - status: 'sent', - payload: Map.from(msg), - attachments: attachments, - ); - - setState(() { - _lastSentId = msgId; - _messages.add(cached); - }); - _clearTyping(senderId); - Haptics.tap(); - _scrollToBottom(); - } - Future _sendMessage() async { final text = _messageController.text.trim(); if (text.isEmpty || _myId == 0) return; @@ -1006,6 +1056,7 @@ class _ChatScreenState extends State nextMessage: nextMessage, chatType: chat?.type ?? 'CHAT', overrideStatus: _effectiveStatus(message), + reactionsListenable: _reactionNotifierFor(message), ); final pressable = _LongPressBubble( diff --git a/lib/frontend/screens/contacts/contact_profile_screen.dart b/lib/frontend/screens/contacts/contact_profile_screen.dart index e502a18..b433648 100644 --- a/lib/frontend/screens/contacts/contact_profile_screen.dart +++ b/lib/frontend/screens/contacts/contact_profile_screen.dart @@ -2,10 +2,9 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; -import '../../../core/protocol/opcode_map.dart'; +import '../../../core/cache/info_cache.dart'; import '../../../core/storage/app_database.dart'; import '../../../core/storage/token_storage.dart'; -import '../../../main.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/swipe_route.dart'; import '../chats/chat_screen.dart'; @@ -41,25 +40,18 @@ class _ContactProfileScreenState extends State { Future _load() async { try { final results = await Future.wait([ - api.sendRequest(Opcode.contactInfo, {'contactIds': [widget.contactId]}), - api.sendRequest(Opcode.contactPresence, {'contactIds': [widget.contactId]}), + ContactInfoFetch.get(widget.contactId), + PresenceFetch.get(widget.contactId), ]); if (!mounted) return; - final infoPacket = results[0]; - if (infoPacket.isOk) { - final contacts = (infoPacket.payload as Map?)?['contacts'] as List?; - if (contacts != null && contacts.isNotEmpty) { - _contact = Map.from(contacts.first as Map); - } + final contact = results[0]; + if (contact != null) { + _contact = contact; } - final presencePacket = results[1]; - if (presencePacket.isOk) { - final presence = (presencePacket.payload as Map?)?['presence'] as Map?; - final p = presence?[widget.contactId.toString()] ?? presence?[widget.contactId]; - if (p is Map) { - _seenTime = p['seen'] as int?; - _presenceStatus = (p['status'] as int?) ?? 0; - } + final presence = results[1]; + if (presence != null) { + _seenTime = presence['seen'] as int?; + _presenceStatus = (presence['status'] as int?) ?? 0; } } catch (e) { if (mounted) showCustomNotification(context, 'Ошибка: $e'); diff --git a/lib/frontend/screens/profile/notifications_screen.dart b/lib/frontend/screens/profile/notifications_screen.dart new file mode 100644 index 0000000..ee5b9f5 --- /dev/null +++ b/lib/frontend/screens/profile/notifications_screen.dart @@ -0,0 +1,291 @@ +import 'package:flutter/material.dart'; +import 'package:m3e_collection/m3e_collection.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +class NotificationsScreen extends StatefulWidget { + const NotificationsScreen({super.key}); + + @override + State createState() => _NotificationsScreenState(); +} + +class _NotificationsScreenState extends State { + bool _fkmEnabled = false; + bool _personalChatsEnabled = true; + bool _groupsEnabled = true; + bool _channelsEnabled = true; + String _selectedSound = 'По умолчанию'; + + static const List _sounds = [ + 'По умолчанию', + 'Колокольчик', + 'Звон', + 'Капля', + 'Беззвучно', + ]; + + Future _pickSound() async { + final cs = Theme.of(context).colorScheme; + final picked = await showModalBottomSheet( + context: context, + backgroundColor: cs.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (context) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 16), + child: Row( + children: [ + Text( + 'Звук уведомления', + style: TextStyle( + color: cs.onSurface, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + for (final s in _sounds) + ListTile( + onTap: () => Navigator.of(context).pop(s), + leading: Icon( + s == _selectedSound + ? Symbols.radio_button_checked + : Symbols.radio_button_unchecked, + color: s == _selectedSound + ? cs.primary + : cs.onSurfaceVariant, + ), + title: Text( + s, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + ), + ), + ), + ], + ), + ), + ); + }, + ); + if (picked != null && picked != _selectedSound) { + setState(() => _selectedSound = picked); + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + + return Scaffold( + backgroundColor: cs.surface, + appBar: AppBarM3E( + titleText: 'Уведомления', + backgroundColor: cs.surface, + ), + body: SafeArea( + top: false, + child: ListView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.fromLTRB(16, 12, 16, 120), + children: [ + _sectionHeader(cs, 'FKM'), + _card(cs, [ + _toggleRow( + cs, + icon: Symbols.notifications_active, + label: 'Включить уведомления', + subtitle: + 'Для работы FKM уведомлений, приложению понадобится держать уведомление в шторке.', + value: _fkmEnabled, + onChanged: (v) => setState(() => _fkmEnabled = v), + ), + ]), + const SizedBox(height: 20), + _sectionHeader(cs, 'Настройки уведомлений'), + _card(cs, [ + _toggleRow( + cs, + icon: Symbols.person, + label: 'Уведомления от личных чатов', + value: _personalChatsEnabled, + onChanged: (v) => setState(() => _personalChatsEnabled = v), + ), + _divider(cs), + _toggleRow( + cs, + icon: Symbols.groups, + label: 'Уведомления от групп', + value: _groupsEnabled, + onChanged: (v) => setState(() => _groupsEnabled = v), + ), + _divider(cs), + _toggleRow( + cs, + icon: Symbols.campaign, + label: 'Уведомления от каналов', + value: _channelsEnabled, + onChanged: (v) => setState(() => _channelsEnabled = v), + ), + ]), + const SizedBox(height: 20), + _sectionHeader(cs, 'Звук'), + _card(cs, [ + _tappableRow( + cs, + icon: Symbols.music_note, + label: 'Звук уведомления', + trailingText: _selectedSound, + onTap: _pickSound, + ), + ]), + ], + ), + ), + ); + } + + Widget _sectionHeader(ColorScheme cs, String title) { + return Padding( + padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), + child: Text( + title, + style: TextStyle( + color: cs.primary, + fontSize: 14, + fontWeight: FontWeight.w600, + letterSpacing: 0.2, + ), + ), + ); + } + + Widget _card(ColorScheme cs, List children) { + return Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + ), + child: Column(children: children), + ); + } + + Widget _divider(ColorScheme cs) { + return Padding( + padding: const EdgeInsets.only(left: 58), + child: Divider( + height: 1, + thickness: 1, + color: cs.outlineVariant.withValues(alpha: 0.35), + ), + ); + } + + Widget _toggleRow( + ColorScheme cs, { + required IconData icon, + required String label, + String? subtitle, + required bool value, + required ValueChanged onChanged, + }) { + return Material( + color: Colors.transparent, + child: InkWell( + onTap: () => onChanged(!value), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + child: Row( + children: [ + Icon(icon, color: cs.onSurfaceVariant, size: 22, weight: 400), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + if (subtitle != null) ...[ + const SizedBox(height: 2), + Text( + subtitle, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + height: 1.3, + ), + ), + ], + ], + ), + ), + const SizedBox(width: 12), + Switch(value: value, onChanged: onChanged), + ], + ), + ), + ), + ); + } + + Widget _tappableRow( + ColorScheme cs, { + required IconData icon, + required String label, + required String trailingText, + required VoidCallback onTap, + }) { + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(20), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 17), + child: Row( + children: [ + Icon(icon, color: cs.onSurfaceVariant, size: 22, weight: 400), + const SizedBox(width: 16), + Expanded( + child: Text( + label, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + Text( + trailingText, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 14, + ), + ), + const SizedBox(width: 6), + Icon(Symbols.chevron_right, color: cs.outline, size: 20), + ], + ), + ), + ), + ); + } +} diff --git a/lib/frontend/screens/profile/settings_tab.dart b/lib/frontend/screens/profile/settings_tab.dart index dd40169..d8c7ba1 100644 --- a/lib/frontend/screens/profile/settings_tab.dart +++ b/lib/frontend/screens/profile/settings_tab.dart @@ -4,6 +4,7 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:package_info_plus/package_info_plus.dart'; +import '../../../backend/modules/chats.dart'; import '../../../backend/modules/messages.dart'; import '../../../core/storage/app_database.dart'; import '../../../core/storage/token_storage.dart'; @@ -20,6 +21,7 @@ import 'debug_menu_screen.dart'; import 'devices_screen.dart'; import 'edit_profile_screen.dart'; import 'info_screen.dart'; +import 'notifications_screen.dart'; import 'security_screen.dart'; import 'spoof_screen.dart'; @@ -206,6 +208,7 @@ class _SettingsTabState extends State { } ContactCache.clear(); TranscriptionCache.clear(); + ChatsModule.resetForAccountSwitch(); try { await api.connect(); } catch (_) {} @@ -309,9 +312,17 @@ child: _buildSection( context, cs, items: [ - const _SettingsItem( + _SettingsItem( icon: Symbols.notifications_active, - label: 'Уведомления и звук', + label: 'Уведомления', + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const NotificationsScreen(), + ), + ); + }, ), _SettingsItem( icon: Symbols.vibration, diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index dfe82b3..363fcf0 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -1,4 +1,5 @@ import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:komet/main.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -59,6 +60,7 @@ class MessageBubble extends StatelessWidget { final CachedMessage? nextMessage; final String chatType; final String? overrideStatus; + final ValueListenable?>? reactionsListenable; const MessageBubble({ super.key, @@ -69,6 +71,7 @@ class MessageBubble extends StatelessWidget { this.nextMessage, required this.chatType, this.overrideStatus, + this.reactionsListenable, }); bool _computeHasPhotoWithCaption() { @@ -304,32 +307,52 @@ class MessageBubble extends StatelessWidget { radius: 15, backgroundColor: Color(0x00000000), ), - ListenableBuilder( - listenable: Listenable.merge( - [AppBubbleShape.current, AppBubbleBehavior.current], - ), - builder: (context, child) { - return Container( - constraints: BoxConstraints( - maxWidth: MediaQuery.sizeOf(context).width * 0.75, + Column( + crossAxisAlignment: + isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, + children: [ + ListenableBuilder( + listenable: Listenable.merge( + [AppBubbleShape.current, AppBubbleBehavior.current], ), - decoration: BoxDecoration( - color: isMe - ? cs.primaryContainer - : cs.surfaceContainerHighest, - borderRadius: _borderRadiusFor( - AppBubbleShape.current.value, - AppBubbleBehavior.current.value, - shape, - hasPhotoCap, - hasMultiPhotos, - ), - ), - padding: padding, - child: child, - ); - }, - child: _buildContent(ctx), + builder: (context, child) { + return Container( + constraints: BoxConstraints( + maxWidth: MediaQuery.sizeOf(context).width * 0.75, + ), + decoration: BoxDecoration( + color: isMe + ? cs.primaryContainer + : cs.surfaceContainerHighest, + borderRadius: _borderRadiusFor( + AppBubbleShape.current.value, + AppBubbleBehavior.current.value, + shape, + hasPhotoCap, + hasMultiPhotos, + ), + ), + padding: padding, + child: child, + ); + }, + child: _buildContent(ctx), + ), + AnimatedSize( + duration: const Duration(milliseconds: 150), + curve: Curves.easeOutCubic, + alignment: isMe + ? Alignment.centerRight + : Alignment.centerLeft, + child: reactionsListenable != null + ? ValueListenableBuilder?>( + valueListenable: reactionsListenable!, + builder: (context, info, _) => + _buildReactionsBarFor(cs, info), + ) + : _buildReactionsBar(cs), + ), + ], ), ], ), @@ -351,6 +374,66 @@ class MessageBubble extends StatelessWidget { } } + Widget _buildReactionsBar(ColorScheme cs) { + final info = message.payload?['reactionInfo']; + return _buildReactionsBarFor(cs, info is Map ? info : null); + } + + Widget _buildReactionsBarFor(ColorScheme cs, Map? info) { + if (info == null) return const SizedBox.shrink(); + final counters = info['counters']; + if (counters is! List || counters.isEmpty) return const SizedBox.shrink(); + final yourReaction = info['yourReaction']?.toString(); + + final chips = []; + for (final c in counters) { + if (c is! Map) continue; + final reaction = c['reaction']?.toString(); + final count = c['count']; + if (reaction == null || reaction.isEmpty) continue; + final isYours = yourReaction == reaction; + chips.add( + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: isYours + ? cs.primary.withValues(alpha: 0.18) + : cs.surfaceContainerHighest.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isYours + ? cs.primary.withValues(alpha: 0.45) + : cs.outlineVariant.withValues(alpha: 0.35), + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(reaction, style: const TextStyle(fontSize: 14)), + if (count is int && count > 1) ...[ + const SizedBox(width: 4), + Text( + count.toString(), + style: TextStyle( + color: isYours ? cs.primary : cs.onSurfaceVariant, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ], + ), + ), + ); + } + if (chips.isEmpty) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.only(top: 4), + child: Wrap(spacing: 4, runSpacing: 4, children: chips), + ); + } + Widget _buildControlContent(ColorScheme cs) { final attachments = message.attachments; if (attachments == null || attachments.isEmpty) { diff --git a/lib/main.dart b/lib/main.dart index 905dedb..9f85669 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -10,6 +10,7 @@ import 'package:m3e_collection/m3e_collection.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'backend/api.dart'; +import 'core/cache/info_cache.dart'; import 'core/config/app_accent.dart'; import 'core/config/app_amoled.dart'; import 'core/config/app_bubble_behavior.dart'; @@ -63,6 +64,7 @@ void main() async { if (activeAccountId != null) { await ContactsModule.primeCacheFromDb(activeAccountId); } + attachInfoCacheApi(api); ChatsModule.attachGlobalPushHandlers(api); await api.connect();