From ebc22cd8bdd19d1c824a17a1898463622195bd88 Mon Sep 17 00:00:00 2001 From: Jganenok Date: Sat, 13 Jun 2026 16:04:37 +0700 Subject: [PATCH] =?UTF-8?q?feat(chats=5Fscreen):=20Real-time=20=D1=80?= =?UTF-8?q?=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=20=D1=81=20lastMsg,=20=D0=B3?= =?UTF-8?q?=D0=B0=D0=BB=D0=BE=D1=87=D0=BA=D0=B8.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/chats.dart | 155 ++++++++++++---- lib/backend/modules/messages.dart | 6 +- lib/backend/modules/outbox.dart | 82 +++++++++ lib/core/cache/info_cache.dart | 10 ++ lib/core/storage/app_database.dart | 35 +++- lib/core/storage/draft_store.dart | 57 ++++++ .../screens/chats/chat_list_screen.dart | 141 ++++++++++++--- lib/frontend/screens/chats/chat_screen.dart | 167 ++++++++++++++---- lib/main.dart | 4 + 9 files changed, 564 insertions(+), 93 deletions(-) create mode 100644 lib/backend/modules/outbox.dart create mode 100644 lib/core/storage/draft_store.dart diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index 8827641..00b114c 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -39,6 +39,7 @@ class CachedChat { final String? lastMsgText; final String? lastMsgTextOneLine; final int? lastMsgSenderId; + final String? lastMsgStatus; final int unreadCount; final int lastEventTime; final int cachedAt; @@ -61,6 +62,7 @@ class CachedChat { this.lastMsgTime, this.lastMsgText, this.lastMsgSenderId, + this.lastMsgStatus, required this.unreadCount, required this.lastEventTime, required this.cachedAt, @@ -78,6 +80,15 @@ class CachedChat { bool get isOfficial => options.contains('OFFICIAL'); + bool get lastMsgReadByOthers { + final t = lastMsgTime; + if (t == null) return false; + for (final entry in participants.entries) { + if (entry.key != accountId && entry.value >= t) return true; + } + return false; + } + bool iAmAdmin(int myId) => owner == myId || admins.contains(myId); bool get isMuted { @@ -96,6 +107,7 @@ class CachedChat { lastMsgTime: row['last_msg_time'] as int?, lastMsgText: row['last_msg_text'] as String?, lastMsgSenderId: row['last_msg_sender'] as int?, + lastMsgStatus: row['last_msg_status'] as String?, unreadCount: row['unread_count'] as int, lastEventTime: row['last_event_time'] as int, cachedAt: row['cached_at'] as int, @@ -133,6 +145,7 @@ class CachedChat { 'last_msg_time': lastMsgTime, 'last_msg_text': lastMsgText, 'last_msg_sender': lastMsgSenderId, + 'last_msg_status': lastMsgStatus, 'unread_count': unreadCount, 'last_event_time': lastEventTime, 'cached_at': cachedAt, @@ -173,6 +186,12 @@ class MessageReactionsChangedEvent extends MessageEvent { const MessageReactionsChangedEvent(super.chatId, this.messageId, this.reactionInfo); } +class MessageSentEvent extends MessageEvent { + final String tempId; + final CachedMessage message; + const MessageSentEvent(super.chatId, this.tempId, this.message); +} + class ChatsModule { static const int muteOff = 0; static const int muteForever = -1; @@ -237,6 +256,63 @@ class ChatsModule { static Stream get messageEvents => _messageEventsController.stream; + static void emitMessageSent(int chatId, String tempId, CachedMessage message) { + _messageEventsController.add(MessageSentEvent(chatId, tempId, message)); + } + + static Future markRead( + Api api, + int accountId, + int chatId, + String messageId, + int mark, + ) async { + final msgIdNum = int.tryParse(messageId); + if (msgIdNum != null) { + try { + await api.sendRequest(Opcode.chatMark, { + 'type': 'READ_MESSAGE', + 'chatId': chatId, + 'messageId': msgIdNum, + 'mark': mark, + }); + } catch (_) {} + } + + final rows = await AppDatabase.loadChat(accountId, chatId); + if (rows.isEmpty) return; + final row = Map.from(rows.first); + if ((row['unread_count'] as int? ?? 0) == 0) return; + row['unread_count'] = 0; + await AppDatabase.saveChats([row]); + _bump(); + } + + static Future applyOutgoing( + int accountId, + int chatId, { + required String messageId, + required int time, + required String text, + required String status, + }) async { + final rows = await AppDatabase.loadChat(accountId, chatId); + if (rows.isEmpty) return; + final row = Map.from(rows.first); + final thisId = int.tryParse(messageId); + final existingTime = (row['last_msg_time'] as int?) ?? 0; + final existingId = row['last_msg_id'] as int?; + if (time < existingTime && existingId != thisId) return; + row['last_msg_id'] = thisId; + row['last_msg_text'] = text; + row['last_msg_time'] = time; + row['last_event_time'] = time; + row['last_msg_sender'] = accountId; + row['last_msg_status'] = status; + await AppDatabase.saveChats([row]); + _bump(); + } + static final ValueNotifier chatsChanged = ValueNotifier(0); static void _bump() => chatsChanged.value = chatsChanged.value + 1; @@ -244,54 +320,35 @@ class ChatsModule { static StreamSubscription? _globalStateSub; static Future _pushQueue = Future.value(); - static final Set _dirtyChats = {}; - static final Set _knownChats = {}; + static final Set _historyFetched = {}; - 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 bool wasHistoryFetched(int chatId) => + _historyFetched.contains(chatId); + static void markHistoryFetched(int chatId) => _historyFetched.add(chatId); static void attachGlobalPushHandlers(Api api) { _globalPushSub?.cancel(); _globalStateSub?.cancel(); _globalPushSub = api.pushStream.listen(_enqueueGlobalPush); _globalStateSub = api.stateStream.listen(_handleSessionState); - if (api.state != SessionState.online) { - _markAllKnownChatsDirty(); - } } - static Future _handleSessionState(SessionState state) async { + static void _handleSessionState(SessionState state) { if (state == SessionState.disconnected) { ContactInfoFetch.clear(); PresenceFetch.clear(); ChatInfoFetch.clear(); - await _markAllKnownChatsDirty(); + _historyFetched.clear(); } } static void resetForAccountSwitch() { - _dirtyChats.clear(); - _knownChats.clear(); + _historyFetched.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 void _enqueueGlobalPush(Packet packet) { _pushQueue = _pushQueue .then((_) => _handleGlobalPush(packet)) @@ -308,9 +365,39 @@ class ChatsModule { await _handleNotifMark(packet); case Opcode.notifMsgReactionsChanged: await _handleNotifMsgReactionsChanged(packet); + case Opcode.notifMsgDelete: + await _handleNotifMsgDelete(packet); } } + static Future _handleNotifMsgDelete(Packet packet) async { + final payload = packet.payload; + if (payload is! Map) return; + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null) return; + + final chatMap = payload['chat']; + int? chatId; + if (chatMap is Map && chatMap['id'] is int) { + chatId = chatMap['id'] as int; + await cacheServerChat(chatMap.cast(), accountId); + } else if (payload['chatId'] is int) { + chatId = payload['chatId'] as int; + } + if (chatId == null) return; + + final ids = payload['messageIds']; + if (ids is List) { + for (final raw in ids) { + final id = raw?.toString(); + if (id == null || id.isEmpty) continue; + await AppDatabase.deleteMessage(accountId, chatId, id); + _messageEventsController.add(MessageRemovedEvent(chatId, id)); + } + } + _bump(); + } + static Future _handleNotifMessage(Packet packet) async { final payload = packet.payload; if (payload is! Map) return; @@ -422,6 +509,7 @@ class ChatsModule { } newRow['last_msg_text'] = messagePreviewText(msg); if (senderId != null) newRow['last_msg_sender'] = senderId; + newRow['last_msg_status'] = 'sent'; } if (unread != null) newRow['unread_count'] = unread; @@ -455,10 +543,12 @@ class ChatsModule { newRow['last_msg_text'] = previewText ?? m['text']; newRow['last_msg_time'] = m['time']; newRow['last_msg_sender'] = m['sender_id']; + newRow['last_msg_status'] = m['status']; } else { newRow['last_msg_id'] = null; newRow['last_msg_text'] = lastMsgPlaceholder; newRow['last_msg_sender'] = null; + newRow['last_msg_status'] = null; } if (unread != null) newRow['unread_count'] = unread; await AppDatabase.saveChats([newRow]); @@ -479,6 +569,13 @@ class ChatsModule { _bump(); } + static Future reconcileLastMessage(int accountId, int chatId) async { + final rows = await AppDatabase.loadChat(accountId, chatId); + if (rows.isEmpty) return; + await _reconcileLastMessage(accountId, chatId, rows.first); + _bump(); + } + static Future _handleNotifMsgReactionsChanged(Packet packet) async { final payload = packet.payload; if (payload is! Map) return; @@ -550,6 +647,7 @@ class ChatsModule { if (cached.participants[userId] == mark) return; cached.participants[userId] = mark; await AppDatabase.saveChats([cached.toDbRow()]); + _bump(); } static final Set _pendingContactUpdates = {}; @@ -655,7 +753,6 @@ 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; @@ -713,6 +810,7 @@ class ChatsModule { // Presence for online statuses final presenceMap = data['presence'] is Map ? data['presence'] as Map : {}; + PresenceFetch.primeAll(presenceMap); final cachedAt = DateTime.now().millisecondsSinceEpoch; final existingRows = await AppDatabase.loadChats(accountId); @@ -752,9 +850,6 @@ class ChatsModule { try { final rows = await AppDatabase.loadChats(accountId); final chats = rows.map(CachedChat.fromDbRow).toList(); - for (final c in chats) { - _knownChats.add(c.id); - } return chats; } catch (e) { logger.e("Ошибка при получении чатов: $e"); diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index d32483b..dd38010 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -376,9 +376,11 @@ class MessagesModule { } if (rows.isNotEmpty) { - AppDatabase.saveMessages(rows).catchError((e) { + try { + await AppDatabase.saveMessages(rows); + } catch (e) { logger.e('saveMessages error: $e'); - }); + } } return results; diff --git a/lib/backend/modules/outbox.dart b/lib/backend/modules/outbox.dart new file mode 100644 index 0000000..6e88847 --- /dev/null +++ b/lib/backend/modules/outbox.dart @@ -0,0 +1,82 @@ +import 'dart:async'; + +import '../../core/storage/app_database.dart'; +import '../../core/storage/token_storage.dart'; +import '../api.dart'; +import 'chats.dart'; +import 'messages.dart'; + +class OutboxService { + OutboxService._(); + + static final OutboxService instance = OutboxService._(); + + Api? _api; + MessagesModule? _messages; + bool _flushing = false; + + void init(Api api, MessagesModule messages) { + if (_api != null) return; + _api = api; + _messages = messages; + api.stateStream.listen((state) { + if (state == SessionState.online) unawaited(flush()); + }); + if (api.state == SessionState.online) unawaited(flush()); + } + + Future flush() async { + if (_flushing) return; + final api = _api; + final messages = _messages; + if (api == null || messages == null) return; + if (api.state != SessionState.online) return; + + _flushing = true; + try { + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null) return; + + final rows = await AppDatabase.loadPendingMessages(accountId); + for (final row in rows) { + if (api.state != SessionState.online) break; + final pending = CachedMessage.fromDbRow(row); + final text = pending.text; + if (text == null || text.isEmpty || pending.payload != null) continue; + + try { + final actualId = + await messages.sendMessage(accountId, pending.chatId, text); + final sent = CachedMessage( + id: actualId.isNotEmpty ? actualId : pending.id, + accountId: accountId, + chatId: pending.chatId, + senderId: accountId, + text: text, + time: pending.time, + status: 'sent', + ); + await AppDatabase.saveMessages([sent.toDbRow()]); + if (sent.id != pending.id) { + await AppDatabase.deleteMessage( + accountId, pending.chatId, pending.id); + } + ChatsModule.emitMessageSent(pending.chatId, pending.id, sent); + await ChatsModule.applyOutgoing( + accountId, + pending.chatId, + messageId: sent.id, + time: sent.time, + text: text, + status: 'sent', + ); + } catch (_) { + break; + } + } + } catch (_) { + } finally { + _flushing = false; + } + } +} diff --git a/lib/core/cache/info_cache.dart b/lib/core/cache/info_cache.dart index 8fdc292..2b8894d 100644 --- a/lib/core/cache/info_cache.dart +++ b/lib/core/cache/info_cache.dart @@ -135,6 +135,16 @@ class PresenceFetch { static void invalidate(int id) => _cache.invalidate(id); static void clear() => _cache.clear(); + static void primeAll(Map presence) { + final now = DateTime.now(); + presence.forEach((key, value) { + if (value is! Map) return; + final id = key is int ? key : int.tryParse(key.toString()); + if (id == null) return; + _cache.putValue(id, Map.from(value), at: now); + }); + } + static Future?> _fetch(int id) async { final results = await _fetchBatch([id]); return results[id]; diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index 8ea71f2..c19c8a2 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -185,7 +185,7 @@ class AppDatabase { await _migrateLegacyDb(target); return openDatabase( target, - version: 11, + version: 12, onOpen: (db) => db.execute('PRAGMA foreign_keys = ON'), onCreate: (db, _) => _createTables(db), onUpgrade: (db, oldVersion, newVersion) async { @@ -238,6 +238,11 @@ class AppDatabase { if (oldVersion < 11) { await _createIndexes(db); } + if (oldVersion < 12) { + await db.execute( + 'ALTER TABLE chats_cache ADD COLUMN last_msg_status TEXT', + ); + } }, ); } @@ -313,6 +318,7 @@ class AppDatabase { last_msg_time INTEGER, last_msg_text TEXT, last_msg_sender INTEGER, + last_msg_status TEXT, unread_count INTEGER NOT NULL DEFAULT 0, last_event_time INTEGER NOT NULL DEFAULT 0, cached_at INTEGER NOT NULL, @@ -480,14 +486,19 @@ class AppDatabase { if (rows.isEmpty) return; try { final db = await _instance; + final cols = rows.first.keys.toList(); + final placeholders = List.filled(cols.length, '?').join(', '); + final updates = cols + .where((c) => c != 'id' && c != 'account_id') + .map((c) => '$c = excluded.$c') + .join(', '); + final sql = 'INSERT INTO chats_cache (${cols.join(', ')}) ' + 'VALUES ($placeholders) ' + 'ON CONFLICT(id, account_id) DO UPDATE SET $updates'; await db.transaction((txn) async { final batch = txn.batch(); for (final row in rows) { - batch.insert( - 'chats_cache', - row, - conflictAlgorithm: ConflictAlgorithm.replace, - ); + batch.rawInsert(sql, cols.map((c) => row[c]).toList()); } await batch.commit(noResult: true); }); @@ -661,4 +672,16 @@ class AppDatabase { whereArgs: [accountId, chatId, messageId], ); } + + static Future>> loadPendingMessages( + int accountId, + ) async { + final db = await _instance; + return db.query( + 'messages', + where: 'account_id = ? AND status = ?', + whereArgs: [accountId, 'pending'], + orderBy: 'time ASC', + ); + } } diff --git a/lib/core/storage/draft_store.dart b/lib/core/storage/draft_store.dart new file mode 100644 index 0000000..26cdd8e --- /dev/null +++ b/lib/core/storage/draft_store.dart @@ -0,0 +1,57 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class DraftStore { + DraftStore._(); + + static final DraftStore instance = DraftStore._(); + + static const String _prefsKey = 'chat_drafts'; + + final Map _drafts = {}; + final ValueNotifier revision = ValueNotifier(0); + bool _loaded = false; + + String _key(int accountId, int chatId) => '$accountId/$chatId'; + + Future load() async { + if (_loaded) return; + _loaded = true; + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_prefsKey); + if (raw == null) return; + try { + final map = jsonDecode(raw); + if (map is Map) { + map.forEach((k, v) { + if (k is String && v is String) _drafts[k] = v; + }); + } + } catch (_) {} + } + + String? get(int accountId, int chatId) { + if (accountId == 0) return null; + return _drafts[_key(accountId, chatId)]; + } + + Future set(int accountId, int chatId, String text) async { + if (accountId == 0) return; + final key = _key(accountId, chatId); + final current = _drafts[key]; + if (text.trim().isEmpty) { + if (current == null) return; + _drafts.remove(key); + } else { + if (current == text) return; + _drafts[key] = text; + } + revision.value++; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_prefsKey, jsonEncode(_drafts)); + } + + Future clear(int accountId, int chatId) => set(accountId, chatId, ''); +} diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 0f2ad61..a1d7726 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -31,6 +31,7 @@ import '../../../backend/modules/chats.dart'; import '../../../backend/modules/cloud_storage.dart'; import '../../../backend/modules/folders.dart'; import '../../../core/storage/app_database.dart'; +import '../../../core/storage/draft_store.dart'; import '../../../core/storage/token_storage.dart'; import '../../../main.dart' show accountModule, api, messagesModule, appRouteObserver; @@ -123,6 +124,7 @@ class _ChatListScreenState extends State Timer? _contactRebuildTimer; bool _deferReloads = false; bool _reloadQueued = false; + bool _reloadInFlight = false; Timer? _settleTimer; bool get _isSelectionMode => _selectedChats.isNotEmpty; bool? _foldersListKnown; @@ -488,8 +490,13 @@ class _ChatListScreenState extends State } }); ChatsModule.chatsChanged.addListener(_onChatsChanged); + DraftStore.instance.revision.addListener(_onDraftsChanged); AppStories.current.addListener(_onStoriesEnabledChanged); - _reloadChatsAndFolders(); + unawaited(_runReload()); + } + + void _onDraftsChanged() { + if (mounted) _requestReload(); } void _onStoriesEnabledChanged() { @@ -526,18 +533,31 @@ class _ChatListScreenState extends State _deferReloads = false; if (_reloadQueued) { _reloadQueued = false; - _reloadChatsAndFolders(); + unawaited(_runReload()); } }); } void _requestReload() { if (!mounted) return; - if (_deferReloads) { + if (_deferReloads || _reloadInFlight) { _reloadQueued = true; return; } - _reloadChatsAndFolders(); + unawaited(_runReload()); + } + + Future _runReload() async { + _reloadInFlight = true; + try { + await _reloadChatsAndFolders(); + } finally { + _reloadInFlight = false; + if (_reloadQueued && mounted && !_deferReloads) { + _reloadQueued = false; + unawaited(_runReload()); + } + } } void _onChatsChanged() { @@ -1019,6 +1039,7 @@ class _ChatListScreenState extends State appRouteObserver.unsubscribe(this); _settleTimer?.cancel(); ChatsModule.chatsChanged.removeListener(_onChatsChanged); + DraftStore.instance.revision.removeListener(_onDraftsChanged); AppStories.current.removeListener(_onStoriesEnabledChanged); _loginSub?.cancel(); _stateSub?.cancel(); @@ -1458,6 +1479,9 @@ class _ChatListScreenState extends State isPinned: isPinned, chatType: "DIALOG", messageItalic: isPlaceholder, + draft: _draftFor(chat.id), + ownStatus: _ownStatusFor(chat, isPlaceholder), + ownRead: chat.lastMsgReadByOthers, ); } else { final isPlaceholder = @@ -1493,6 +1517,9 @@ class _ChatListScreenState extends State isPinned: isPinned, chatType: chat.type, messageItalic: isPlaceholder, + draft: chat.id == 0 ? null : _draftFor(chat.id), + ownStatus: _ownStatusFor(chat, isPlaceholder), + ownRead: chat.lastMsgReadByOthers, ); } }, childCount: totalItems), @@ -2054,6 +2081,46 @@ class _ChatListScreenState extends State ); } + String? _draftFor(int chatId) { + final raw = DraftStore.instance.get(_profile?.id ?? 0, chatId); + if (raw == null) return null; + final oneLine = raw.replaceAll('\n', ' ').trim(); + return oneLine.isEmpty ? null : oneLine; + } + + String? _ownStatusFor(CachedChat chat, bool isPlaceholder) { + if (isPlaceholder || chat.id == 0) return null; + final me = _profile?.id; + if (me == null || chat.lastMsgSenderId != me) return null; + return chat.lastMsgStatus ?? 'sent'; + } + + Widget _ownStatusIcon(ColorScheme cs, String status, bool read) { + IconData icon; + Color color; + switch (status) { + case 'sending': + case 'pending': + icon = Symbols.schedule; + color = cs.outline; + case 'error': + icon = Symbols.error; + color = Colors.redAccent; + default: + if (read) { + icon = Symbols.done_all; + color = const Color(0xFF4FC3F7); + } else { + icon = Symbols.check; + color = cs.outline; + } + } + return Padding( + padding: const EdgeInsets.only(left: 6), + child: Icon(icon, size: 16, color: color, fill: 1), + ); + } + Widget _buildChatItem( String id, String name, @@ -2069,9 +2136,15 @@ class _ChatListScreenState extends State bool isPinned = false, String chatType = "CHAT", bool messageItalic = false, + String? draft, + String? ownStatus, + bool ownRead = false, }) { final cs = Theme.of(context).colorScheme; final isSelected = _selectedChats.contains(id); + final Widget? statusIcon = (ownStatus != null && draft == null) + ? _ownStatusIcon(cs, ownStatus, ownRead) + : null; return InkWell( key: ValueKey('chat_$id'), @@ -2255,23 +2328,47 @@ class _ChatListScreenState extends State crossAxisAlignment: CrossAxisAlignment.end, children: [ Expanded( - child: Text( - message, - style: TextStyle( - color: isTyping ? cs.primary : cs.outline, - fontSize: 14, - fontWeight: isTyping - ? FontWeight.w500 - : FontWeight.w400, - fontStyle: messageItalic - ? FontStyle.italic - : FontStyle.normal, - height: 1.2, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), + child: draft != null + ? Text.rich( + TextSpan( + children: [ + TextSpan( + text: 'Черновик: ', + style: TextStyle(color: cs.error), + ), + TextSpan( + text: draft, + style: TextStyle(color: cs.outline), + ), + ], + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w400, + fontStyle: FontStyle.italic, + height: 1.2, + ), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ) + : Text( + message, + style: TextStyle( + color: isTyping ? cs.primary : cs.outline, + fontSize: 14, + fontWeight: isTyping + ? FontWeight.w500 + : FontWeight.w400, + fontStyle: messageItalic + ? FontStyle.italic + : FontStyle.normal, + height: 1.2, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), ), + ?statusIcon, const SizedBox(width: 8), if (unreadCount > 0) Container( @@ -2282,13 +2379,13 @@ class _ChatListScreenState extends State decoration: BoxDecoration( color: isMuted ? cs.surfaceContainerHighest - : cs.surfaceContainerHigh, + : cs.primary, borderRadius: BorderRadius.circular(10), ), child: Text( unreadCount.toString(), style: TextStyle( - color: isMuted ? cs.outline : cs.onSurface, + color: isMuted ? cs.outline : cs.onPrimary, fontSize: 11, fontWeight: FontWeight.w600, height: 1.1, diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 82c31fb..c35de32 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -20,12 +20,14 @@ import 'package:komet/frontend/screens/chats/poll_create_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/calls/call_controller.dart'; import '../calls/call_screen.dart'; import '../../../core/protocol/opcode_map.dart'; import '../../../core/protocol/packet.dart'; import '../../../core/storage/app_database.dart'; +import '../../../core/storage/draft_store.dart'; import '../../../core/cache/info_cache.dart'; import '../../../core/utils/haptics.dart'; import '../../../core/config/app_cache_extent.dart'; @@ -89,7 +91,8 @@ class ChatScreen extends StatefulWidget { State createState() => _ChatScreenState(); } -class _ChatScreenState extends State with TickerProviderStateMixin { +class _ChatScreenState extends State + with TickerProviderStateMixin, WidgetsBindingObserver { final TextEditingController _messageController = TextEditingController(); final FocusNode _messageFocusNode = FocusNode(); double _keyboardReserve = 0; @@ -170,10 +173,12 @@ class _ChatScreenState extends State with TickerProviderStateMixin { late final CurvedAnimation _floatingDateCurved; final Map _separatorKeys = {}; String? _lastSentId; + String? _lastMarkedId; @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); _messageController.addListener(_onTextChanged); _scrollController.addListener(_onScrollForDate); AppVisualStyle.current.addListener(_onVisualStyleChanged); @@ -226,6 +231,7 @@ class _ChatScreenState extends State with TickerProviderStateMixin { final p = await AppDatabase.loadActiveProfile(); if (!mounted) return; _myId = p?.id ?? 0; + _restoreDraft(); ChatsModule.getChat(_myId, widget.chatId) .then((value) { @@ -233,6 +239,7 @@ class _ChatScreenState extends State with TickerProviderStateMixin { setState(() { chat = value.first; }); + _seedPresenceFromChat(); _recomputeHeaderStatus(); _syncOtherReadTime(); } @@ -300,6 +307,18 @@ class _ChatScreenState extends State with TickerProviderStateMixin { _shimmerStartTimer?.cancel(); _shimmerStartTimer = null; if (_shimmerController.isAnimating) _shimmerController.stop(); + _markRead(); + } + + void _markRead() { + if (_myId == 0 || _messages.isEmpty) return; + final newest = _messages.last; + if (newest.senderId == _myId) return; + if (newest.id == _lastMarkedId) return; + _lastMarkedId = newest.id; + unawaited( + ChatsModule.markRead(api, _myId, widget.chatId, newest.id, newest.time), + ); } Future _loadHistory() async { @@ -325,7 +344,8 @@ class _ChatScreenState extends State with TickerProviderStateMixin { _applyMergedMessages(fullDecoded); } - if (!ChatsModule.isChatDirty(widget.chatId) && fullRows.isNotEmpty) { + if (fullRows.isNotEmpty && + ChatsModule.wasHistoryFetched(widget.chatId)) { if (mounted) { setState(() { _isLoading = false; @@ -338,7 +358,7 @@ class _ChatScreenState extends State with TickerProviderStateMixin { try { await messagesModule.fetchHistory(_myId, widget.chatId); - ChatsModule.markChatClean(widget.chatId); + ChatsModule.markHistoryFetched(widget.chatId); final updatedRows = await AppDatabase.loadMessages( _myId, widget.chatId, @@ -431,8 +451,25 @@ class _ChatScreenState extends State with TickerProviderStateMixin { return true; } + @override + void deactivate() { + _saveDraft(); + super.deactivate(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.paused || + state == AppLifecycleState.inactive) { + _saveDraft(); + } + super.didChangeAppLifecycleState(state); + } + @override void dispose() { + WidgetsBinding.instance.removeObserver(this); + _saveDraft(); _messageController.removeListener(_onTextChanged); _scrollController.removeListener(_onScrollForDate); AppVisualStyle.current.removeListener(_onVisualStyleChanged); @@ -479,6 +516,22 @@ class _ChatScreenState extends State with TickerProviderStateMixin { } } + void _restoreDraft() { + if (_myId == 0 || _messageController.text.isNotEmpty) return; + final draft = DraftStore.instance.get(_myId, widget.chatId); + if (draft == null || draft.isEmpty) return; + _messageController.text = draft; + _messageController.selection = + TextSelection.collapsed(offset: draft.length); + } + + void _saveDraft() { + if (_myId == 0) return; + unawaited( + DraftStore.instance.set(_myId, widget.chatId, _messageController.text), + ); + } + void _onAttachPanelToggle() { if (_showAttachmentPanel.value) { _attachAnim.forward(); @@ -777,6 +830,7 @@ class _ChatScreenState extends State with TickerProviderStateMixin { _bumpMessages(); try { await AppDatabase.deleteMessage(_myId, widget.chatId, messageId); + await ChatsModule.reconcileLastMessage(_myId, widget.chatId); } catch (_) {} } @@ -864,11 +918,18 @@ class _ChatScreenState extends State with TickerProviderStateMixin { Haptics.tap(); _scrollToBottom(); _checkPrankTrigger(message); + _markRead(); case MessageEditedEvent(:final message): final idx = _messages.indexWhere((m) => m.id == message.id); if (idx == -1) return; _messages[idx] = message; _bumpMessages(); + case MessageSentEvent(:final tempId, :final message): + final idx = _messages.indexWhere((m) => m.id == tempId); + if (idx == -1) return; + _lastSentId = message.id; + _messages[idx] = message; + _bumpMessages(); case MessageRemovedEvent(:final messageId): final idx = _messages.indexWhere((m) => m.id == messageId); if (idx == -1) return; @@ -1063,6 +1124,17 @@ class _ChatScreenState extends State with TickerProviderStateMixin { } } + void _seedPresenceFromChat() { + if (widget.chatType != 'DIALOG' || _myId == 0) return; + if (_otherStatus != 0 || _otherSeenTime != null) return; + final otherId = widget.chatId ^ _myId; + if (otherId <= 0) return; + final p = PresenceFetch.peek(otherId); + if (p == null) return; + _otherStatus = (p['status'] as int?) ?? 0; + _otherSeenTime = p['seen'] as int?; + } + void _recomputeHeaderStatus() { _headerStatusNotifier.value = _headerStatus(); } @@ -1078,7 +1150,7 @@ class _ChatScreenState extends State with TickerProviderStateMixin { return '$count подписчиков'; } if (_otherStatus == 1) return 'В сети'; - if (_otherStatus == 3) return 'Был(-а) недавно'; + if (_otherStatus == 2 || _otherStatus == 3) return 'Был(-а) недавно'; final s = _otherSeenTime; if (s != null && s > 0) return formatLastSeen(s); return ''; @@ -1132,32 +1204,46 @@ class _ChatScreenState extends State with TickerProviderStateMixin { final tempId = _nextTempId(); final now = DateTime.now().millisecondsSinceEpoch; + final online = api.state == SessionState.online; + + final composed = CachedMessage( + id: tempId, + accountId: _myId, + chatId: widget.chatId, + senderId: _myId, + text: text, + time: now, + status: online ? 'sending' : 'pending', + ); + + _hasText.value = false; + _lastSentId = tempId; + _messages.add(composed); + _messageController.clear(); + if (DraftStore.instance.get(_myId, widget.chatId) != null) { + unawaited(DraftStore.instance.clear(_myId, widget.chatId)); + } + _bumpMessages(); + unawaited(_persistOutgoing(composed)); + unawaited(ChatsModule.applyOutgoing( + _myId, + widget.chatId, + messageId: tempId, + time: now, + text: text, + status: composed.status ?? 'sending', + )); + + // Instant tactile "whoosh" the moment the message leaves the composer, + // not after the network round-trip — feedback must feel immediate. + Haptics.send(); + + _scrollToBottom(); + _checkPrankTrigger(composed); + + if (!online) return; try { - final tempMessage = CachedMessage( - id: tempId, - accountId: _myId, - chatId: widget.chatId, - senderId: _myId, - text: text, - time: now, - status: 'sending', - ); - - _hasText.value = false; - _lastSentId = tempId; - _messages.add(tempMessage); - _messageController.clear(); - _bumpMessages(); - unawaited(_persistOutgoing(tempMessage)); - - // Instant tactile "whoosh" the moment the message leaves the composer, - // not after the network round-trip — feedback must feel immediate. - Haptics.send(); - - _scrollToBottom(); - _checkPrankTrigger(tempMessage); - final actualId = await messagesModule.sendMessage( _myId, widget.chatId, @@ -1178,6 +1264,14 @@ class _ChatScreenState extends State with TickerProviderStateMixin { _messages[index] = sent; _bumpMessages(); unawaited(_persistOutgoing(sent, removeId: tempId)); + unawaited(ChatsModule.applyOutgoing( + _myId, + widget.chatId, + messageId: sent.id, + time: now, + text: text, + status: 'sent', + )); } if (chat == null) { @@ -1190,21 +1284,28 @@ class _ChatScreenState extends State with TickerProviderStateMixin { ); } } catch (e) { - Haptics.error(); final index = _messages.indexWhere((m) => m.id == tempId); if (index != -1 && mounted) { - final failed = CachedMessage( + final queued = CachedMessage( id: tempId, accountId: _myId, chatId: widget.chatId, senderId: _myId, text: text, time: now, - status: 'error', + status: 'pending', ); - _messages[index] = failed; + _messages[index] = queued; _bumpMessages(); - unawaited(_persistOutgoing(failed)); + unawaited(_persistOutgoing(queued)); + unawaited(ChatsModule.applyOutgoing( + _myId, + widget.chatId, + messageId: tempId, + time: now, + text: text, + status: 'pending', + )); } } } diff --git a/lib/main.dart b/lib/main.dart index 08dcddf..5f75b96 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -12,6 +12,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'backend/api.dart'; import 'core/cache/info_cache.dart'; import 'core/storage/app_instance.dart'; +import 'core/storage/draft_store.dart'; import 'core/config/app_accent.dart'; import 'core/config/app_amoled.dart'; import 'core/config/app_bubble_behavior.dart'; @@ -34,6 +35,7 @@ import 'backend/modules/chats.dart'; import 'backend/modules/contacts.dart'; import 'backend/modules/file_uploader.dart'; import 'backend/modules/messages.dart'; +import 'backend/modules/outbox.dart'; import 'backend/modules/polls.dart'; import 'backend/modules/webapp.dart'; import 'backend/modules/digital_id.dart'; @@ -120,6 +122,7 @@ void main() async { final prefs = await prefsFuture; await FileHistoryCache.load(prefs); + await DraftStore.instance.load(); final initialFpsOverlay = prefs.getBool('dev_fps_overlay') ?? false; final initialVpnBypass = prefs.getBool(VpnBypassService.prefKey) ?? false; final initialTlsInsecure = prefs.getBool(TlsConfig.prefKey) ?? false; @@ -251,6 +254,7 @@ class KometAppState extends State _loginStatusSub = accountModule.loginStatusStream.listen((status) async { if (status == LoginStatus.success) { CallController.instance.init(api); + OutboxService.instance.init(api, messagesModule); if (isOnemeFlavor) { await PushService.instance.init(api: api, account: accountModule); await PushService.instance.onLoginSuccess();