feat(chats_screen): Real-time работа с lastMsg, галочки.
This commit is contained in:
+125
-30
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+10
@@ -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];
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, '');
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user