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