feat(chats_screen): Real-time работа с lastMsg, галочки.

This commit is contained in:
Jganenok
2026-06-13 16:06:41 +07:00
parent efa50375e0
commit ebc22cd8bd
9 changed files with 564 additions and 93 deletions
+125 -30
View File
@@ -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<MessageEvent> get messageEvents =>
_messageEventsController.stream;
static void emitMessageSent(int chatId, String tempId, CachedMessage message) {
_messageEventsController.add(MessageSentEvent(chatId, tempId, message));
}
static Future<void> 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<String, dynamic>.from(rows.first);
if ((row['unread_count'] as int? ?? 0) == 0) return;
row['unread_count'] = 0;
await AppDatabase.saveChats([row]);
_bump();
}
static Future<void> 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<String, dynamic>.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<int> chatsChanged = ValueNotifier(0);
static void _bump() => chatsChanged.value = chatsChanged.value + 1;
@@ -244,54 +320,35 @@ class ChatsModule {
static StreamSubscription<SessionState>? _globalStateSub;
static Future<void> _pushQueue = Future.value();
static final Set<int> _dirtyChats = {};
static final Set<int> _knownChats = {};
static final Set<int> _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<void> _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<void> _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<void> _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<dynamic, dynamic>(), 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<void> _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<void> 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<void> _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<int> _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");
+4 -2
View File
@@ -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;
+82
View File
@@ -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<void> 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;
}
}
}
+10
View File
@@ -135,6 +135,16 @@ class PresenceFetch {
static void invalidate(int id) => _cache.invalidate(id);
static void clear() => _cache.clear();
static void primeAll(Map<dynamic, dynamic> 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<String, dynamic>.from(value), at: now);
});
}
static Future<Map<String, dynamic>?> _fetch(int id) async {
final results = await _fetchBatch([id]);
return results[id];
+29 -6
View File
@@ -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<List<Map<String, dynamic>>> loadPendingMessages(
int accountId,
) async {
final db = await _instance;
return db.query(
'messages',
where: 'account_id = ? AND status = ?',
whereArgs: [accountId, 'pending'],
orderBy: 'time ASC',
);
}
}
+57
View File
@@ -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<String, String> _drafts = {};
final ValueNotifier<int> revision = ValueNotifier(0);
bool _loaded = false;
String _key(int accountId, int chatId) => '$accountId/$chatId';
Future<void> 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<void> 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<void> clear(int accountId, int chatId) => set(accountId, chatId, '');
}
+119 -22
View File
@@ -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<ChatListScreen>
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<ChatListScreen>
}
});
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<ChatListScreen>
_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<void> _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<ChatListScreen>
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<ChatListScreen>
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<ChatListScreen>
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<ChatListScreen>
);
}
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<ChatListScreen>
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<ChatListScreen>
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<ChatListScreen>
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,
+134 -33
View File
@@ -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<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
class _ChatScreenState extends State<ChatScreen>
with TickerProviderStateMixin, WidgetsBindingObserver {
final TextEditingController _messageController = TextEditingController();
final FocusNode _messageFocusNode = FocusNode();
double _keyboardReserve = 0;
@@ -170,10 +173,12 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
late final CurvedAnimation _floatingDateCurved;
final Map<int, GlobalKey> _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<ChatScreen> 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<ChatScreen> with TickerProviderStateMixin {
setState(() {
chat = value.first;
});
_seedPresenceFromChat();
_recomputeHeaderStatus();
_syncOtherReadTime();
}
@@ -300,6 +307,18 @@ class _ChatScreenState extends State<ChatScreen> 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<void> _loadHistory() async {
@@ -325,7 +344,8 @@ class _ChatScreenState extends State<ChatScreen> 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<ChatScreen> 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<ChatScreen> 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<ChatScreen> 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<ChatScreen> 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<ChatScreen> 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<ChatScreen> 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<ChatScreen> 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<ChatScreen> 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<ChatScreen> 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<ChatScreen> 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',
));
}
}
}
+4
View File
@@ -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<KometApp>
_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();