1740 lines
54 KiB
Dart
1740 lines
54 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
|
|
import '../../core/config/komet_settings.dart';
|
|
import '../../core/protocol/opcode_map.dart';
|
|
import '../../core/protocol/packet.dart';
|
|
import '../../core/cache/info_cache.dart';
|
|
import '../../core/cache/message_session_cache.dart';
|
|
import '../../core/storage/app_database.dart';
|
|
import '../../core/storage/token_storage.dart';
|
|
import '../../core/utils/logger.dart';
|
|
import '../../core/utils/text_format.dart';
|
|
import '../api.dart';
|
|
import 'folders.dart';
|
|
import 'messages.dart' show ContactCache, CachedMessage;
|
|
|
|
Map<int, int> _parseParticipants(dynamic raw) {
|
|
try {
|
|
final decoded = raw is String ? jsonDecode(raw) : raw;
|
|
if (decoded is Map) {
|
|
return decoded.map((k, v) => MapEntry(
|
|
k is int ? k : int.parse(k.toString()),
|
|
v is int ? v : int.tryParse(v.toString()) ?? 0,
|
|
));
|
|
}
|
|
} catch (e) {
|
|
logger.e('Failed to parse participants: $e');
|
|
}
|
|
return {};
|
|
}
|
|
|
|
class CachedChat {
|
|
final int id;
|
|
final int accountId;
|
|
final String type;
|
|
final String? title;
|
|
final String? iconUrl;
|
|
final int? lastMsgId;
|
|
final int? lastMsgTime;
|
|
final String? lastMsgText;
|
|
final String? lastMsgTextOneLine;
|
|
final String? lastMsgElements;
|
|
final int? lastMsgSenderId;
|
|
final String? lastMsgStatus;
|
|
final int unreadCount;
|
|
final int lastEventTime;
|
|
final int cachedAt;
|
|
final int? favIndex;
|
|
final int dontDisturbUntil;
|
|
final bool isOnline;
|
|
final int seenTime;
|
|
final Map<int, int> participants;
|
|
final Set<String> options;
|
|
final int? owner;
|
|
final Set<int> admins;
|
|
|
|
CachedChat({
|
|
required this.id,
|
|
required this.accountId,
|
|
required this.type,
|
|
this.title,
|
|
this.iconUrl,
|
|
this.lastMsgId,
|
|
this.lastMsgTime,
|
|
this.lastMsgText,
|
|
this.lastMsgElements,
|
|
this.lastMsgSenderId,
|
|
this.lastMsgStatus,
|
|
required this.unreadCount,
|
|
required this.lastEventTime,
|
|
required this.cachedAt,
|
|
this.favIndex,
|
|
required this.dontDisturbUntil,
|
|
required this.isOnline,
|
|
required this.seenTime,
|
|
required this.participants,
|
|
this.options = const {},
|
|
this.owner,
|
|
this.admins = const {},
|
|
}) : lastMsgTextOneLine = lastMsgText != null && lastMsgText.contains('\n')
|
|
? lastMsgText.replaceAll('\n', ' ')
|
|
: lastMsgText;
|
|
|
|
bool get isOfficial => options.contains('OFFICIAL');
|
|
|
|
List<FormatRange> get lastMsgFormatRanges {
|
|
final raw = lastMsgElements;
|
|
if (raw == null || raw.isEmpty) return const [];
|
|
try {
|
|
return parseFormatElements(jsonDecode(raw));
|
|
} catch (_) {
|
|
return const [];
|
|
}
|
|
}
|
|
|
|
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 {
|
|
if (dontDisturbUntil == ChatsModule.muteOff) return false;
|
|
if (dontDisturbUntil < 0) return true;
|
|
return dontDisturbUntil > DateTime.now().millisecondsSinceEpoch;
|
|
}
|
|
|
|
factory CachedChat.fromDbRow(Map<String, dynamic> row) => CachedChat(
|
|
id: row['id'] as int,
|
|
accountId: row['account_id'] as int,
|
|
type: row['type'] as String,
|
|
title: row['title'] as String?,
|
|
iconUrl: row['icon_url'] as String?,
|
|
lastMsgId: row['last_msg_id'] as int?,
|
|
lastMsgTime: row['last_msg_time'] as int?,
|
|
lastMsgText: row['last_msg_text'] as String?,
|
|
lastMsgElements: row['last_msg_elements'] 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,
|
|
favIndex: row['fav_index'] as int?,
|
|
dontDisturbUntil: row['dont_disturb_until'] as int,
|
|
isOnline: (row['is_online'] as int) == 1,
|
|
seenTime: row['seen_time'] as int,
|
|
participants: _parseParticipants(row['participants']),
|
|
options: _decodeOptions(row['options']),
|
|
owner: row['owner'] as int?,
|
|
admins: _decodeAdmins(row['admins']),
|
|
);
|
|
|
|
static Set<String> _decodeOptions(dynamic raw) {
|
|
if (raw is! String || raw.isEmpty) return const {};
|
|
return raw.split(',').where((s) => s.isNotEmpty).toSet();
|
|
}
|
|
|
|
static Set<int> _decodeAdmins(dynamic raw) {
|
|
if (raw is! String || raw.isEmpty) return const {};
|
|
return raw
|
|
.split(',')
|
|
.map((s) => int.tryParse(s.trim()))
|
|
.whereType<int>()
|
|
.toSet();
|
|
}
|
|
|
|
Map<String, dynamic> toDbRow() => {
|
|
'id': id,
|
|
'account_id': accountId,
|
|
'type': type,
|
|
'title': title,
|
|
'icon_url': iconUrl,
|
|
'last_msg_id': lastMsgId,
|
|
'last_msg_time': lastMsgTime,
|
|
'last_msg_text': lastMsgText,
|
|
'last_msg_elements': lastMsgElements,
|
|
'last_msg_sender': lastMsgSenderId,
|
|
'last_msg_status': lastMsgStatus,
|
|
'unread_count': unreadCount,
|
|
'last_event_time': lastEventTime,
|
|
'cached_at': cachedAt,
|
|
'fav_index': favIndex,
|
|
'dont_disturb_until': dontDisturbUntil,
|
|
'is_online': isOnline ? 1 : 0,
|
|
'seen_time': seenTime,
|
|
'participants': jsonEncode(participants.map((k, v) => MapEntry(k.toString(), v))),
|
|
'options': options.isEmpty ? null : options.join(','),
|
|
'owner': owner,
|
|
'admins': admins.isEmpty ? null : admins.join(','),
|
|
};
|
|
}
|
|
|
|
class ChatSearchHit {
|
|
final int id;
|
|
final String type;
|
|
final String? title;
|
|
final String? avatarUrl;
|
|
final String? subtitle;
|
|
|
|
const ChatSearchHit({
|
|
required this.id,
|
|
required this.type,
|
|
this.title,
|
|
this.avatarUrl,
|
|
this.subtitle,
|
|
});
|
|
}
|
|
|
|
class MessageSearchHit {
|
|
final int chatId;
|
|
final String? messageId;
|
|
final String? text;
|
|
final int time;
|
|
final int senderId;
|
|
|
|
const MessageSearchHit({
|
|
required this.chatId,
|
|
this.messageId,
|
|
this.text,
|
|
required this.time,
|
|
required this.senderId,
|
|
});
|
|
}
|
|
|
|
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 MessageMarkedDeletedEvent extends MessageEvent {
|
|
final String messageId;
|
|
const MessageMarkedDeletedEvent(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 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;
|
|
|
|
/// Sentinel в `lastMsgText` когда последнее сообщение в чате удалено,
|
|
/// а кеша истории нет — UI должен отрисовать курсивную плашку.
|
|
static const String lastMsgPlaceholder = '__komet_lastmsg_placeholder__';
|
|
|
|
static String? attachPreviewLabel(dynamic attaches) {
|
|
if (attaches is! List || attaches.isEmpty) return null;
|
|
final first = attaches.first;
|
|
if (first is! Map) return null;
|
|
final type = (first['_type'] as String? ?? '').toUpperCase();
|
|
switch (type) {
|
|
case 'PHOTO':
|
|
return 'Фото';
|
|
case 'VIDEO':
|
|
return 'Видео';
|
|
case 'AUDIO':
|
|
return 'Голосовое сообщение';
|
|
case 'FILE':
|
|
final name = first['name']?.toString();
|
|
return name != null && name.isNotEmpty ? 'Файл: $name' : 'Файл';
|
|
case 'STICKER':
|
|
return 'Стикер';
|
|
case 'SHARE':
|
|
final title = first['title']?.toString();
|
|
return title != null && title.isNotEmpty ? 'Ссылка: $title' : 'Ссылка';
|
|
case 'POLL':
|
|
final title = first['title']?.toString();
|
|
return title != null && title.isNotEmpty ? 'Опрос: $title' : 'Опрос';
|
|
case 'LOCATION':
|
|
return 'Геопозиция';
|
|
case 'CONTACT':
|
|
return 'Контакт';
|
|
case 'CONTROL':
|
|
return _controlPreviewLabel(first);
|
|
case 'INLINE_KEYBOARD':
|
|
return null;
|
|
case 'CALL':
|
|
final video = first['callType']?.toString().toUpperCase() == 'VIDEO';
|
|
final dur = (first['duration'] as num?)?.toInt() ?? 0;
|
|
final hangup = first['hangupType']?.toString();
|
|
final failed = dur == 0 ||
|
|
hangup == 'CANCELED' ||
|
|
hangup == 'REJECTED' ||
|
|
hangup == 'MISSED';
|
|
if (first['joinLink'] != null) {
|
|
return video ? 'Групповой видеозвонок' : 'Групповой звонок';
|
|
}
|
|
if (failed) return video ? 'Пропущенный видеозвонок' : 'Пропущенный звонок';
|
|
return video ? 'Видеозвонок' : 'Звонок';
|
|
default:
|
|
return 'Вложение';
|
|
}
|
|
}
|
|
|
|
static String? _controlPreviewLabel(Map c) {
|
|
final title = c['title']?.toString();
|
|
if (title != null && title.isNotEmpty) return title;
|
|
final short = c['shortMessage']?.toString();
|
|
if (short != null && short.isNotEmpty) return short;
|
|
switch (c['event']?.toString()) {
|
|
case 'new':
|
|
return 'Чат создан';
|
|
case 'add':
|
|
case 'joinByLink':
|
|
return 'Новый участник';
|
|
case 'leave':
|
|
return 'Участник вышел';
|
|
case 'remove':
|
|
return 'Участник удалён';
|
|
case 'pin':
|
|
return 'Закреплённое сообщение';
|
|
case 'changeTitle':
|
|
return 'Название чата изменено';
|
|
case 'changeIcon':
|
|
return 'Фото чата обновлено';
|
|
default:
|
|
return 'Системное сообщение';
|
|
}
|
|
}
|
|
|
|
static String? messagePreviewText(Map msg) {
|
|
final link = msg['link'];
|
|
if (link is Map && link['type']?.toString().toUpperCase() == 'FORWARD') {
|
|
final original = link['message'];
|
|
final inner = original is Map ? _bodyPreviewText(original) : null;
|
|
return inner != null && inner.isNotEmpty
|
|
? '↪ $inner'
|
|
: '↪ Пересланное сообщение';
|
|
}
|
|
return _bodyPreviewText(msg);
|
|
}
|
|
|
|
static String? _bodyPreviewText(Map msg) {
|
|
final text = msg['text']?.toString();
|
|
if (text != null && text.isNotEmpty) return text;
|
|
return attachPreviewLabel(msg['attaches']);
|
|
}
|
|
|
|
static String? messagePreviewElements(Map msg) {
|
|
final text = msg['text'];
|
|
if (text is! String || text.isEmpty) return null;
|
|
final elements = msg['elements'];
|
|
if (elements is List && elements.isNotEmpty) return jsonEncode(elements);
|
|
return null;
|
|
}
|
|
|
|
static final _messageEventsController =
|
|
StreamController<MessageEvent>.broadcast();
|
|
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 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;
|
|
|
|
final msgIdNum = int.tryParse(messageId);
|
|
if (msgIdNum != null && !KometSettings.antiRead.value) {
|
|
try {
|
|
await api.sendRequest(Opcode.chatMark, {
|
|
'type': 'READ_MESSAGE',
|
|
'chatId': chatId,
|
|
'messageId': msgIdNum,
|
|
'mark': mark,
|
|
});
|
|
} catch (_) {}
|
|
}
|
|
|
|
row['unread_count'] = 0;
|
|
await AppDatabase.saveChats([row]);
|
|
_bump();
|
|
}
|
|
|
|
static Future<int?> markUnread(
|
|
Api api,
|
|
int accountId,
|
|
int chatId,
|
|
int mark,
|
|
) async {
|
|
int? unread;
|
|
try {
|
|
final resp = await api.sendRequest(Opcode.chatMark, {
|
|
'type': 'SET_AS_UNREAD',
|
|
'chatId': chatId,
|
|
'mark': mark,
|
|
});
|
|
final payload = resp.payload;
|
|
if (payload is Map) unread = payload['unread'] as int?;
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
if (unread == null) return null;
|
|
|
|
final rows = await AppDatabase.loadChat(accountId, chatId);
|
|
if (rows.isNotEmpty) {
|
|
final row = Map<String, dynamic>.from(rows.first);
|
|
row['unread_count'] = unread;
|
|
await AppDatabase.saveChats([row]);
|
|
_bump();
|
|
}
|
|
return unread;
|
|
}
|
|
|
|
static Future<void> applyOutgoing(
|
|
int accountId,
|
|
int chatId, {
|
|
required String messageId,
|
|
required int time,
|
|
required String text,
|
|
required String status,
|
|
List<Map<String, dynamic>>? elements,
|
|
}) 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_elements'] = (elements != null && elements.isNotEmpty)
|
|
? jsonEncode(elements)
|
|
: null;
|
|
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;
|
|
|
|
static StreamSubscription<Packet>? _globalPushSub;
|
|
static StreamSubscription<SessionState>? _globalStateSub;
|
|
static Future<void> _pushQueue = Future.value();
|
|
|
|
static final Set<int> _historyFetched = {};
|
|
|
|
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);
|
|
}
|
|
|
|
static void _handleSessionState(SessionState state) {
|
|
if (state == SessionState.disconnected) {
|
|
ContactInfoFetch.clear();
|
|
PresenceFetch.clear();
|
|
ChatInfoFetch.clear();
|
|
_historyFetched.clear();
|
|
}
|
|
}
|
|
|
|
static void resetForAccountSwitch() {
|
|
_historyFetched.clear();
|
|
ContactInfoFetch.clear();
|
|
PresenceFetch.clear();
|
|
ChatInfoFetch.clear();
|
|
}
|
|
|
|
static void _enqueueGlobalPush(Packet packet) {
|
|
_pushQueue = _pushQueue
|
|
.then((_) => _handleGlobalPush(packet))
|
|
.catchError((Object e) {
|
|
logger.w('Ошибка обработки пуша: $e');
|
|
});
|
|
}
|
|
|
|
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);
|
|
case Opcode.notifMsgDelete:
|
|
await _handleNotifMsgDelete(packet);
|
|
case Opcode.notifPresence:
|
|
_handlePresence(packet);
|
|
}
|
|
}
|
|
|
|
static void _handlePresence(Packet packet) {
|
|
final payload = packet.payload;
|
|
if (payload is! Map) return;
|
|
final userId = payload['userId'];
|
|
if (userId is! int) return;
|
|
final presence = payload['presence'];
|
|
if (presence is! Map) return;
|
|
PresenceFetch.apply(userId, Map<String, dynamic>.from(presence));
|
|
}
|
|
|
|
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 keepDeleted = KometSettings.viewDeleted.value;
|
|
final ids = payload['messageIds'];
|
|
if (ids is List) {
|
|
for (final raw in ids) {
|
|
final id = raw?.toString();
|
|
if (id == null || id.isEmpty) continue;
|
|
if (keepDeleted) {
|
|
await AppDatabase.markMessageDeleted(accountId, chatId, id);
|
|
_messageEventsController.add(MessageMarkedDeletedEvent(chatId, id));
|
|
} else {
|
|
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;
|
|
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) {
|
|
final keepDeleted = KometSettings.viewDeleted.value;
|
|
if (keepDeleted) {
|
|
await AppDatabase.markMessageDeleted(accountId, chatId, msgIdStr);
|
|
} else {
|
|
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(
|
|
keepDeleted
|
|
? MessageMarkedDeletedEvent(chatId, msgIdStr)
|
|
: 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);
|
|
if (KometSettings.viewRedacted.value) {
|
|
final oldText = existing['text']?.toString();
|
|
if ((oldText ?? '') != (msgText ?? '') &&
|
|
oldText != null &&
|
|
oldText.isNotEmpty) {
|
|
final history = CachedMessage.appendEditHistory(
|
|
CachedMessage.parseEditHistory(existing['edit_history']),
|
|
oldText,
|
|
DateTime.now().millisecondsSinceEpoch,
|
|
);
|
|
newRow['edit_history'] = jsonEncode(history);
|
|
}
|
|
}
|
|
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'] = messagePreviewText(msg);
|
|
newRow['last_msg_elements'] = messagePreviewElements(msg);
|
|
if (senderId != null) newRow['last_msg_sender'] = senderId;
|
|
newRow['last_msg_status'] = 'sent';
|
|
}
|
|
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,
|
|
onlyVisible: true,
|
|
);
|
|
final newRow = Map<String, dynamic>.from(chatRow);
|
|
if (latest.isNotEmpty) {
|
|
final m = latest.first;
|
|
final rawText = m['text']?.toString();
|
|
String? previewText = rawText;
|
|
String? elementsJson;
|
|
if (rawText == null || rawText.isEmpty) {
|
|
final payloadRaw = m['payload'];
|
|
if (payloadRaw is String && payloadRaw.isNotEmpty) {
|
|
try {
|
|
final payload = jsonDecode(payloadRaw);
|
|
if (payload is Map) {
|
|
previewText = messagePreviewText(payload);
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
} else {
|
|
final payloadRaw = m['payload'];
|
|
if (payloadRaw is String && payloadRaw.isNotEmpty) {
|
|
try {
|
|
final payload = jsonDecode(payloadRaw);
|
|
if (payload is Map) {
|
|
final els = payload['elements'];
|
|
if (els is List && els.isNotEmpty) elementsJson = jsonEncode(els);
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
}
|
|
newRow['last_msg_id'] = int.tryParse(m['id']?.toString() ?? '');
|
|
newRow['last_msg_text'] = previewText ?? m['text'];
|
|
newRow['last_msg_elements'] = elementsJson;
|
|
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_elements'] = null;
|
|
newRow['last_msg_sender'] = null;
|
|
newRow['last_msg_status'] = 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> 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<List<String>> reconcileDeletedFromFetch(
|
|
int accountId,
|
|
int chatId,
|
|
List<CachedMessage> serverMessages,
|
|
) async {
|
|
if (serverMessages.isEmpty) return const [];
|
|
|
|
final serverIds = <String>{};
|
|
var minTime = serverMessages.first.time;
|
|
var maxTime = serverMessages.first.time;
|
|
for (final m in serverMessages) {
|
|
serverIds.add(m.id);
|
|
if (m.time < minTime) minTime = m.time;
|
|
if (m.time > maxTime) maxTime = m.time;
|
|
}
|
|
|
|
final cached = await AppDatabase.loadMessages(
|
|
accountId,
|
|
chatId,
|
|
limit: 300,
|
|
onlyVisible: true,
|
|
);
|
|
|
|
final newlyDeleted = <String>[];
|
|
for (final row in cached) {
|
|
final id = row['id']?.toString();
|
|
if (id == null || id.isEmpty || id.startsWith('temp_')) continue;
|
|
if (serverIds.contains(id)) continue;
|
|
|
|
final status = row['status']?.toString();
|
|
if (status == 'pending' || status == 'sending' || status == 'error') {
|
|
continue;
|
|
}
|
|
|
|
final time = row['time'] is int
|
|
? row['time'] as int
|
|
: int.tryParse(row['time']?.toString() ?? '') ?? 0;
|
|
if (time < minTime || time > maxTime) continue;
|
|
|
|
newlyDeleted.add(id);
|
|
}
|
|
|
|
if (newlyDeleted.isNotEmpty) {
|
|
await AppDatabase.markMessagesDeleted(accountId, chatId, newlyDeleted);
|
|
}
|
|
return newlyDeleted;
|
|
}
|
|
|
|
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;
|
|
final chatId = payload['chatId'];
|
|
if (chatId is! int) return;
|
|
final userId = payload['userId'];
|
|
if (userId is! int) return;
|
|
final mark = payload['mark'];
|
|
if (mark is! int) return;
|
|
if (payload['setAsUnread'] == true) return;
|
|
|
|
final accountId = await TokenStorage.getActiveAccountId();
|
|
if (accountId == null) return;
|
|
|
|
final rows = await AppDatabase.loadChat(accountId, chatId);
|
|
if (rows.isEmpty) return;
|
|
final cached = CachedChat.fromDbRow(rows.first);
|
|
if (cached.participants[userId] == mark) return;
|
|
cached.participants[userId] = mark;
|
|
await AppDatabase.saveChats([cached.toDbRow()]);
|
|
_bump();
|
|
}
|
|
|
|
static final Set<int> _pendingContactUpdates = {};
|
|
static Timer? _contactFlushTimer;
|
|
static Future<void>? _contactFlushFuture;
|
|
static const _contactFlushDelay = Duration(milliseconds: 250);
|
|
|
|
static void applyContactUpdate(int contactId) {
|
|
_pendingContactUpdates.add(contactId);
|
|
if (_contactFlushTimer != null) return;
|
|
if (_contactFlushFuture != null) return;
|
|
_contactFlushTimer = Timer(_contactFlushDelay, _kickFlush);
|
|
}
|
|
|
|
static void _kickFlush() {
|
|
_contactFlushTimer = null;
|
|
if (_contactFlushFuture != null) return;
|
|
_contactFlushFuture = _flushContactUpdates().whenComplete(() {
|
|
_contactFlushFuture = null;
|
|
if (_pendingContactUpdates.isNotEmpty) {
|
|
_contactFlushTimer ??= Timer(_contactFlushDelay, _kickFlush);
|
|
}
|
|
});
|
|
}
|
|
|
|
static Future<void> _flushContactUpdates() async {
|
|
if (_pendingContactUpdates.isEmpty) return;
|
|
final ids = _pendingContactUpdates.toList();
|
|
_pendingContactUpdates.clear();
|
|
|
|
final accountId = await TokenStorage.getActiveAccountId();
|
|
if (accountId == null) return;
|
|
|
|
final dialogRows = await AppDatabase.loadDialogChats(accountId);
|
|
final byParticipant =
|
|
<int, List<({Map<String, dynamic> row, CachedChat cached})>>{};
|
|
for (final row in dialogRows) {
|
|
final cached = CachedChat.fromDbRow(row);
|
|
for (final pid in cached.participants.keys) {
|
|
if (pid == accountId) continue;
|
|
byParticipant
|
|
.putIfAbsent(pid, () => [])
|
|
.add((row: row, cached: cached));
|
|
}
|
|
}
|
|
|
|
final updates = <Map<String, dynamic>>[];
|
|
for (final contactId in ids) {
|
|
final name = ContactCache.get(contactId);
|
|
if (name == null) continue;
|
|
final avatar = ContactCache.getAvatar(contactId);
|
|
final options = ContactCache.getOptions(contactId) ?? const <String>{};
|
|
final affected = byParticipant[contactId];
|
|
if (affected == null) continue;
|
|
for (final entry in affected) {
|
|
final row = entry.row;
|
|
final cached = entry.cached;
|
|
final sameTitle = cached.title == name;
|
|
final sameAvatar = (cached.iconUrl ?? '') == (avatar ?? '');
|
|
final sameOptions = cached.options.length == options.length &&
|
|
cached.options.containsAll(options);
|
|
if (sameTitle && sameAvatar && sameOptions) continue;
|
|
final newRow = Map<String, dynamic>.from(row);
|
|
newRow['title'] = name;
|
|
newRow['icon_url'] = avatar;
|
|
newRow['options'] = options.isEmpty ? null : options.join(',');
|
|
updates.add(newRow);
|
|
}
|
|
}
|
|
if (updates.isNotEmpty) {
|
|
await AppDatabase.saveChats(updates);
|
|
_bump();
|
|
}
|
|
}
|
|
|
|
static Future<CachedChat?> cacheServerChat(
|
|
Map<dynamic, dynamic> chat,
|
|
int accountId, {
|
|
Map<int, CachedChat>? preloadedExisting,
|
|
bool inList = true,
|
|
}) async {
|
|
final cachedAt = DateTime.now().millisecondsSinceEpoch;
|
|
final id = chat['id'];
|
|
Map<int, CachedChat> existing = const {};
|
|
if (preloadedExisting != null) {
|
|
existing = preloadedExisting;
|
|
} else if (id is int) {
|
|
final rows = await AppDatabase.loadChat(accountId, id);
|
|
if (rows.isNotEmpty) {
|
|
existing = {id: CachedChat.fromDbRow(rows.first)};
|
|
}
|
|
}
|
|
final parsed = _parseChat(
|
|
chat,
|
|
accountId,
|
|
accountId,
|
|
const {},
|
|
const {},
|
|
const {},
|
|
existing,
|
|
cachedAt,
|
|
);
|
|
if (parsed == null) {
|
|
logger.w('cacheServerChat: parse returned null for chat=${chat['id']}');
|
|
return null;
|
|
}
|
|
final ex = existing[parsed.id];
|
|
if (ex != null && _sameContent(ex, parsed)) {
|
|
return parsed;
|
|
}
|
|
final row = parsed.toDbRow();
|
|
row['in_list'] = inList ? 1 : 0;
|
|
await AppDatabase.saveChats([row]);
|
|
_bump();
|
|
return parsed;
|
|
}
|
|
|
|
static bool _sameContent(CachedChat a, CachedChat b) {
|
|
if (a.title != b.title) return false;
|
|
if (a.iconUrl != b.iconUrl) return false;
|
|
if (a.owner != b.owner) return false;
|
|
if (a.dontDisturbUntil != b.dontDisturbUntil) return false;
|
|
if (a.favIndex != b.favIndex) return false;
|
|
if (a.lastMsgId != b.lastMsgId) return false;
|
|
if (a.lastMsgTime != b.lastMsgTime) return false;
|
|
if (a.lastMsgText != b.lastMsgText) return false;
|
|
if (a.lastMsgElements != b.lastMsgElements) return false;
|
|
if (a.lastMsgSenderId != b.lastMsgSenderId) return false;
|
|
if (a.unreadCount != b.unreadCount) return false;
|
|
if (a.lastEventTime != b.lastEventTime) return false;
|
|
if (a.isOnline != b.isOnline) return false;
|
|
if (a.seenTime != b.seenTime) return false;
|
|
if (a.admins.length != b.admins.length) return false;
|
|
if (!a.admins.containsAll(b.admins)) return false;
|
|
if (a.options.length != b.options.length) return false;
|
|
if (!a.options.containsAll(b.options)) return false;
|
|
if (a.participants.length != b.participants.length) return false;
|
|
for (final e in a.participants.entries) {
|
|
if (b.participants[e.key] != e.value) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/// Парсит и кэширует чаты из payload opcode 19.
|
|
///
|
|
/// Для диалогов разрезолвит имя и аватар из списка [contacts] того же
|
|
/// ответа. На warm start контакты не приходят — используется существующий
|
|
/// кэш.
|
|
static Future<void> syncFromLoginPayload(
|
|
Map<dynamic, dynamic> data,
|
|
int accountId,
|
|
int currentUserId,
|
|
) async {
|
|
try {
|
|
final chats = data['chats'];
|
|
if (chats is! List || chats.isEmpty) return;
|
|
|
|
final contactsMap = _buildContactsMap(data['contacts']);
|
|
// Config contains mute setup and fav indexes: config -> chats -> id
|
|
final configMap = data['config'] is Map ? data['config'] as Map : {};
|
|
final chatsConfig = configMap['chats'] is Map
|
|
? configMap['chats'] as Map
|
|
: {};
|
|
|
|
// 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);
|
|
final existing = {
|
|
for (final row in existingRows)
|
|
row['id'] as int: CachedChat.fromDbRow(row),
|
|
};
|
|
|
|
final rows = chats
|
|
.whereType<Map>()
|
|
.map(
|
|
(c) => _parseChat(
|
|
c.cast<dynamic, dynamic>(),
|
|
accountId,
|
|
currentUserId,
|
|
contactsMap,
|
|
chatsConfig,
|
|
presenceMap,
|
|
existing,
|
|
cachedAt,
|
|
),
|
|
)
|
|
.whereType<CachedChat>()
|
|
.map((c) => c.toDbRow()..['in_list'] = 1)
|
|
.toList();
|
|
|
|
if (rows.isNotEmpty) {
|
|
await AppDatabase.saveChats(rows);
|
|
_bump();
|
|
}
|
|
} catch (e) {
|
|
logger.e("Ошибка при синке: $e");
|
|
}
|
|
}
|
|
|
|
static Future<List<CachedChat>> getChats(int accountId) async {
|
|
try {
|
|
final rows = await AppDatabase.loadChats(accountId);
|
|
final chats = rows.map(CachedChat.fromDbRow).toList();
|
|
return chats;
|
|
} catch (e) {
|
|
logger.e("Ошибка при получении чатов: $e");
|
|
return [];
|
|
}
|
|
}
|
|
static Future<List<CachedChat>> getChat(int accountId, int chatId) async {
|
|
try {
|
|
final rows = await AppDatabase.loadChat(accountId, chatId);
|
|
|
|
return rows.map(CachedChat.fromDbRow).toList();
|
|
} catch (e) {
|
|
logger.e("Ошибка при получении чата: $e");
|
|
|
|
return [];
|
|
}
|
|
}
|
|
|
|
static Future<void> clearCache(int accountId) =>
|
|
AppDatabase.clearChatsCache(accountId);
|
|
|
|
static Map<int, Map<dynamic, dynamic>> _buildContactsMap(dynamic contacts) {
|
|
if (contacts is! List) return {};
|
|
final result = <int, Map<dynamic, dynamic>>{};
|
|
for (final c in contacts.whereType<Map>()) {
|
|
final id = c['id'];
|
|
if (id is int) result[id] = c.cast();
|
|
}
|
|
return result;
|
|
}
|
|
|
|
static CachedChat? _parseChat(
|
|
Map<dynamic, dynamic> chat,
|
|
int accountId,
|
|
int currentUserId,
|
|
Map<int, Map<dynamic, dynamic>> contactsMap,
|
|
Map<dynamic, dynamic> chatsConfig,
|
|
Map<dynamic, dynamic> presenceMap,
|
|
Map<int, CachedChat> existing,
|
|
int cachedAt,
|
|
) {
|
|
try {
|
|
final id = chat['id'];
|
|
if (id is! int) return null;
|
|
|
|
final type = (chat['type'] as String?) ?? 'DIALOG';
|
|
int? otherId;
|
|
|
|
String? title;
|
|
String? iconUrl;
|
|
Set<String> options = const {};
|
|
|
|
if (type == 'DIALOG') {
|
|
otherId = _otherParticipantId(chat['participants'], currentUserId);
|
|
final contact = otherId != null ? contactsMap[otherId] : null;
|
|
|
|
if (contact != null) {
|
|
title = _nameFromContact(contact);
|
|
iconUrl = contact['baseUrl'] as String?;
|
|
final contactOpts = contact['options'];
|
|
if (contactOpts is List) {
|
|
options = contactOpts.whereType<String>().toSet();
|
|
}
|
|
} else {
|
|
title = existing[id]?.title;
|
|
iconUrl = existing[id]?.iconUrl;
|
|
options = existing[id]?.options ?? const {};
|
|
}
|
|
} else {
|
|
title = chat['title'] as String?;
|
|
iconUrl = chat['baseIconUrl'] as String?;
|
|
final chatOpts = chat['options'];
|
|
if (chatOpts is Map) {
|
|
options = {
|
|
for (final entry in chatOpts.entries)
|
|
if (entry.value == true && entry.key is String) entry.key as String,
|
|
};
|
|
}
|
|
}
|
|
|
|
final lastMsg = chat['lastMessage'];
|
|
int? lastMsgId;
|
|
int? lastMsgTime;
|
|
String? lastMsgText;
|
|
String? lastMsgElements;
|
|
int? lastMsgSenderId;
|
|
|
|
if (lastMsg is Map) {
|
|
lastMsgId = lastMsg['id'] as int?;
|
|
lastMsgTime = lastMsg['time'] as int?;
|
|
lastMsgText = messagePreviewText(lastMsg);
|
|
lastMsgElements = messagePreviewElements(lastMsg);
|
|
lastMsgSenderId = lastMsg['sender'] as int?;
|
|
}
|
|
|
|
final config = chatsConfig[id.toString()] ?? chatsConfig[id];
|
|
int? favIndex;
|
|
int dontDisturbUntil = 0;
|
|
if (config is Map) {
|
|
favIndex = config['favIndex'] as int?;
|
|
dontDisturbUntil = (config['dontDisturbUntil'] as int?) ?? 0;
|
|
} else {
|
|
final ex = existing[id];
|
|
if (ex != null) {
|
|
favIndex = ex.favIndex;
|
|
dontDisturbUntil = ex.dontDisturbUntil;
|
|
}
|
|
}
|
|
|
|
|
|
int seenTime = 0;
|
|
bool isOnline = false;
|
|
if (type == 'DIALOG' && otherId != null) {
|
|
final presence = presenceMap[otherId.toString()] ?? presenceMap[otherId];
|
|
if (presence is Map) {
|
|
seenTime = (presence['seen'] as int?) ?? 0;
|
|
isOnline = (presence['status'] as int?) == 1;
|
|
}
|
|
}
|
|
Map<int, int> participants = _parseParticipants(chat['participants']);
|
|
|
|
int? owner;
|
|
final ownerRaw = chat['owner'];
|
|
if (ownerRaw is int) {
|
|
owner = ownerRaw;
|
|
} else if (ownerRaw is String) {
|
|
owner = int.tryParse(ownerRaw);
|
|
}
|
|
|
|
Set<int> admins = const {};
|
|
final adminsRaw = chat['admins'];
|
|
if (adminsRaw is List) {
|
|
admins = adminsRaw
|
|
.map((e) => e is int ? e : int.tryParse(e.toString()))
|
|
.whereType<int>()
|
|
.toSet();
|
|
} else {
|
|
final adminParticipants = chat['adminParticipants'];
|
|
if (adminParticipants is Map) {
|
|
admins = adminParticipants.keys
|
|
.map((k) => k is int ? k : int.tryParse(k.toString()))
|
|
.whereType<int>()
|
|
.toSet();
|
|
}
|
|
}
|
|
|
|
return CachedChat(
|
|
id: id,
|
|
accountId: accountId,
|
|
type: type,
|
|
title: title,
|
|
iconUrl: iconUrl,
|
|
lastMsgId: lastMsgId,
|
|
lastMsgTime: lastMsgTime,
|
|
lastMsgText: lastMsgText,
|
|
lastMsgElements: lastMsgElements,
|
|
lastMsgSenderId: lastMsgSenderId,
|
|
unreadCount: (chat['newMessages'] as int?) ?? 0,
|
|
lastEventTime: (chat['lastEventTime'] as int?) ?? 0,
|
|
cachedAt: cachedAt,
|
|
favIndex: favIndex,
|
|
dontDisturbUntil: dontDisturbUntil,
|
|
isOnline: isOnline,
|
|
seenTime: seenTime,
|
|
participants: participants,
|
|
options: options,
|
|
owner: owner,
|
|
admins: admins,
|
|
);
|
|
} catch (e) {
|
|
logger.e("Ошибка при парсинге чата: $e");
|
|
|
|
return null;
|
|
}
|
|
}
|
|
|
|
static int? _otherParticipantId(dynamic participants, int currentUserId) {
|
|
if (participants is! Map) return null;
|
|
for (final key in participants.keys) {
|
|
final id = key is int ? key : int.tryParse(key.toString());
|
|
if (id != null && id != currentUserId) return id;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
static String? _nameFromContact(Map<dynamic, dynamic> contact) {
|
|
final names = contact['names'];
|
|
if (names is! List || names.isEmpty) return null;
|
|
final nameRaw = names.firstWhere(
|
|
(n) => n is Map && n['type'] == 'ONEME',
|
|
orElse: () => names.firstWhere((n) => n is Map, orElse: () => null),
|
|
);
|
|
if (nameRaw is! Map) return null;
|
|
final name = nameRaw;
|
|
return name['name'] as String?;
|
|
}
|
|
|
|
static Future<Map<String, dynamic>?> getChatInfo(Api api, int chatId) async {
|
|
final packet = await api.sendRequest(Opcode.chatInfo, {
|
|
'chatIds': [chatId],
|
|
});
|
|
if (packet.isError) return null;
|
|
final payload = packet.payload as Map?;
|
|
final chats = payload?['chats'] as List?;
|
|
if (chats == null || chats.isEmpty) return null;
|
|
return Map<String, dynamic>.from(chats.first as Map);
|
|
}
|
|
|
|
static Future<dynamic> searchById(Api api, int userId) async {
|
|
final packet = await api.sendRequest(Opcode.publicSearch, {
|
|
'query': userId.toString(),
|
|
'from': 0,
|
|
'count': 10,
|
|
});
|
|
return packet.payload;
|
|
}
|
|
|
|
static List<ChatSearchHit> _parseSearchResult(dynamic payload) {
|
|
final result = (payload as Map?)?['result'];
|
|
if (result is! List) return const [];
|
|
final hits = <ChatSearchHit>[];
|
|
for (final item in result) {
|
|
if (item is! Map) continue;
|
|
final chat = item['chat'];
|
|
if (chat is! Map) continue;
|
|
final id = chat['id'];
|
|
if (id is! int) continue;
|
|
final last = chat['lastMessage'];
|
|
final link = chat['link'];
|
|
hits.add(ChatSearchHit(
|
|
id: id,
|
|
type: (chat['type'] as String?) ?? 'CHAT',
|
|
title: chat['title'] as String?,
|
|
avatarUrl: chat['baseIconUrl'] as String?,
|
|
subtitle: link is String && link.isNotEmpty
|
|
? '@$link'
|
|
: (last is Map ? last['text'] as String? : null),
|
|
));
|
|
}
|
|
return hits;
|
|
}
|
|
|
|
static List<MessageSearchHit> _parseMessageResult(dynamic payload) {
|
|
final result = (payload as Map?)?['result'];
|
|
if (result is! List) return const [];
|
|
final hits = <MessageSearchHit>[];
|
|
for (final item in result) {
|
|
if (item is! Map) continue;
|
|
final message = item['message'];
|
|
if (message is! Map) continue;
|
|
final chatId = item['chatId'];
|
|
if (chatId is! int || chatId == 0) continue;
|
|
hits.add(MessageSearchHit(
|
|
chatId: chatId,
|
|
messageId: message['id']?.toString(),
|
|
text: message['text'] as String?,
|
|
time: (message['time'] as int?) ?? 0,
|
|
senderId: (message['sender'] as int?) ?? 0,
|
|
));
|
|
}
|
|
return hits;
|
|
}
|
|
|
|
static Future<List<MessageSearchHit>> searchMessages(
|
|
Api api,
|
|
String query, {
|
|
int count = 50,
|
|
}) async {
|
|
final term = query.trim();
|
|
if (term.isEmpty) return const [];
|
|
try {
|
|
final packet = await api.sendRequest(Opcode.chatSearch, {
|
|
'count': count,
|
|
'query': term,
|
|
});
|
|
if (packet.isError) return const [];
|
|
return _parseMessageResult(packet.payload);
|
|
} catch (e) {
|
|
logger.w('searchMessages failed: $e');
|
|
return const [];
|
|
}
|
|
}
|
|
|
|
static Future<List<ChatSearchHit>> searchPublic(
|
|
Api api,
|
|
String query, {
|
|
int count = 20,
|
|
}) async {
|
|
final term = query.trim();
|
|
if (term.isEmpty) return const [];
|
|
try {
|
|
final packet = await api.sendRequest(Opcode.publicSearch, {
|
|
'type': 'ALL',
|
|
'count': count,
|
|
'query': term,
|
|
});
|
|
if (packet.isError) return const [];
|
|
return _parseSearchResult(packet.payload);
|
|
} catch (e) {
|
|
logger.w('searchPublic failed: $e');
|
|
return const [];
|
|
}
|
|
}
|
|
|
|
static Future<void> subscribeChat(
|
|
Api api,
|
|
int chatId, {
|
|
bool subscribe = true,
|
|
}) async {
|
|
try {
|
|
await api.sendRequest(Opcode.chatSubscribe, {
|
|
'chatId': chatId,
|
|
'subscribe': subscribe,
|
|
});
|
|
} catch (e) {
|
|
logger.w('subscribeChat failed: $e');
|
|
}
|
|
}
|
|
|
|
static Future<bool> ensureChatCached(
|
|
Api api,
|
|
int accountId,
|
|
int chatId,
|
|
) async {
|
|
final rows = await AppDatabase.loadChat(accountId, chatId);
|
|
if (rows.isNotEmpty) return true;
|
|
try {
|
|
final info = await getChatInfo(api, chatId);
|
|
if (info == null) return false;
|
|
await cacheServerChat(info, accountId, inList: false);
|
|
return true;
|
|
} catch (e) {
|
|
logger.w('ensureChatCached failed for $chatId: $e');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
static Future<CachedChat?> createGroupChat(
|
|
Api api, {
|
|
required String title,
|
|
required List<int> userIds,
|
|
bool notify = true,
|
|
}) async {
|
|
final payload = {
|
|
'message': {
|
|
'cid': DateTime.now().millisecondsSinceEpoch,
|
|
'attaches': [
|
|
{
|
|
'_type': 'CONTROL',
|
|
'event': 'new',
|
|
'chatType': 'CHAT',
|
|
'title': title,
|
|
'userIds': userIds,
|
|
},
|
|
],
|
|
},
|
|
'notify': notify,
|
|
};
|
|
final packet = await api.sendRequest(Opcode.msgSend, payload);
|
|
if (!packet.isOk) {
|
|
logger.w('createGroupChat: server error payload=${packet.payload}');
|
|
return null;
|
|
}
|
|
final data = packet.payload;
|
|
if (data is! Map) {
|
|
logger.w('createGroupChat: payload is not a Map: $data');
|
|
return null;
|
|
}
|
|
final chat = data['chat'];
|
|
if (chat is! Map) {
|
|
logger.w('createGroupChat: response has no chat field: $data');
|
|
return null;
|
|
}
|
|
final accountId = await TokenStorage.getActiveAccountId();
|
|
if (accountId == null) {
|
|
logger.w('createGroupChat: no active account id');
|
|
return null;
|
|
}
|
|
return cacheServerChat(chat, accountId);
|
|
}
|
|
|
|
static Future<String?> requestChatPhotoUploadUrl(Api api) async {
|
|
final packet = await api.sendRequest(Opcode.photoUpload, {'count': 1});
|
|
if (!packet.isOk) return null;
|
|
final data = packet.payload;
|
|
if (data is! Map) return null;
|
|
return data['url'] as String?;
|
|
}
|
|
|
|
static Future<bool> setChatPhoto(
|
|
Api api, {
|
|
required int chatId,
|
|
required String photoToken,
|
|
}) async {
|
|
final packet = await api.sendRequest(Opcode.chatUpdate, {
|
|
'chatId': chatId,
|
|
'photoToken': photoToken,
|
|
});
|
|
return packet.isOk;
|
|
}
|
|
|
|
static Future<bool> setChatOptions(
|
|
Api api, {
|
|
required int chatId,
|
|
required Map<String, dynamic> options,
|
|
}) async {
|
|
final packet = await api.sendRequest(Opcode.chatUpdate, {
|
|
'chatId': chatId,
|
|
'options': options,
|
|
});
|
|
return packet.isOk;
|
|
}
|
|
|
|
static Future<bool> setChatTitle(
|
|
Api api, {
|
|
required int chatId,
|
|
required String title,
|
|
}) async {
|
|
final packet = await api.sendRequest(Opcode.chatUpdate, {
|
|
'chatId': chatId,
|
|
'theme': title,
|
|
});
|
|
if (!packet.isOk) return false;
|
|
final accountId = await TokenStorage.getActiveAccountId();
|
|
if (accountId == null) return true;
|
|
final rows = await AppDatabase.loadChat(accountId, chatId);
|
|
if (rows.isNotEmpty) {
|
|
final updated = Map<String, dynamic>.from(rows.first);
|
|
updated['title'] = title;
|
|
await AppDatabase.saveChats([updated]);
|
|
_bump();
|
|
}
|
|
return true;
|
|
}
|
|
|
|
static Future<String?> togglePin(
|
|
Api api, {
|
|
required List<int> chatIds,
|
|
required bool pin,
|
|
}) async {
|
|
if (chatIds.isEmpty) return null;
|
|
try {
|
|
final accountId = await TokenStorage.getActiveAccountId();
|
|
if (accountId == null) return 'Нет активного аккаунта';
|
|
final folders = await FoldersModule.loadFolders(accountId);
|
|
final allFolder = folders.firstWhere(
|
|
FoldersModule.isAllChatsFolder,
|
|
orElse: () => folders.isEmpty
|
|
? throw StateError('Папка "Все" не найдена')
|
|
: folders.first,
|
|
);
|
|
|
|
final favorites = List<int>.from(allFolder.favorites ?? const []);
|
|
if (pin) {
|
|
for (final id in chatIds) {
|
|
if (!favorites.contains(id)) favorites.add(id);
|
|
}
|
|
} else {
|
|
favorites.removeWhere((id) => chatIds.contains(id));
|
|
}
|
|
|
|
await FoldersModule.setFolderFavorites(api, accountId, allFolder, favorites);
|
|
|
|
final existingRows = await AppDatabase.loadChatsByIds(accountId, chatIds);
|
|
final updates = <Map<String, dynamic>>[];
|
|
for (final row in existingRows) {
|
|
final id = row['id'] as int;
|
|
final isFav = favorites.contains(id);
|
|
final currentFav = row['fav_index'] as int?;
|
|
final newFav = isFav
|
|
? ((currentFav ?? 0) > 0 ? currentFav : favorites.indexOf(id) + 1)
|
|
: 0;
|
|
if (currentFav == newFav) continue;
|
|
final newRow = Map<String, dynamic>.from(row);
|
|
newRow['fav_index'] = newFav;
|
|
updates.add(newRow);
|
|
}
|
|
if (updates.isNotEmpty) {
|
|
await AppDatabase.saveChats(updates);
|
|
_bump();
|
|
}
|
|
return null;
|
|
} on PacketError catch (e) {
|
|
logger.w('togglePin: ${e.message}');
|
|
return e.message;
|
|
} catch (e) {
|
|
logger.w('togglePin: $e');
|
|
return 'Не удалось изменить закрепление';
|
|
}
|
|
}
|
|
|
|
static Future<String?> setChatMute(
|
|
Api api, {
|
|
required int chatId,
|
|
required int dontDisturbUntil,
|
|
}) async {
|
|
try {
|
|
await api.sendRequest(Opcode.config, {
|
|
'settings': {
|
|
'chats': {
|
|
chatId: {'dontDisturbUntil': dontDisturbUntil},
|
|
},
|
|
},
|
|
});
|
|
final accountId = await TokenStorage.getActiveAccountId();
|
|
if (accountId != null) {
|
|
final rows = await AppDatabase.loadChat(accountId, chatId);
|
|
if (rows.isNotEmpty) {
|
|
final row = Map<String, dynamic>.from(rows.first);
|
|
row['dont_disturb_until'] = dontDisturbUntil;
|
|
await AppDatabase.saveChats([row]);
|
|
_bump();
|
|
}
|
|
}
|
|
return null;
|
|
} on PacketError catch (e) {
|
|
logger.w('setChatMute $chatId: ${e.message}');
|
|
return e.message;
|
|
} catch (e) {
|
|
logger.w('setChatMute $chatId: $e');
|
|
return 'Не удалось изменить уведомления';
|
|
}
|
|
}
|
|
|
|
static Future<String?> deleteChat(
|
|
Api api, {
|
|
required int chatId,
|
|
required int lastEventTime,
|
|
required bool forAll,
|
|
}) async {
|
|
try {
|
|
await api.sendRequest(Opcode.chatDelete, {
|
|
'chatId': chatId,
|
|
'lastEventTime': lastEventTime,
|
|
'forAll': forAll,
|
|
});
|
|
final accountId = await TokenStorage.getActiveAccountId();
|
|
if (accountId != null) {
|
|
await AppDatabase.deleteChat(chatId, accountId);
|
|
_bump();
|
|
}
|
|
return null;
|
|
} on PacketError catch (e) {
|
|
logger.w('deleteChat $chatId: ${e.message}');
|
|
return e.message;
|
|
} catch (e) {
|
|
logger.w('deleteChat $chatId: $e');
|
|
return 'Не удалось удалить чат';
|
|
}
|
|
}
|
|
|
|
static Future<String?> clearHistory(
|
|
Api api, {
|
|
required int chatId,
|
|
required int lastEventTime,
|
|
bool forAll = false,
|
|
}) async {
|
|
try {
|
|
await api.sendRequest(Opcode.chatClear, {
|
|
'chatId': chatId,
|
|
'lastEventTime': lastEventTime,
|
|
'forAll': forAll,
|
|
});
|
|
final accountId = await TokenStorage.getActiveAccountId();
|
|
if (accountId != null) {
|
|
await AppDatabase.clearMessages(accountId, chatId);
|
|
MessageSessionCache.remove(accountId, chatId);
|
|
_historyFetched.remove(chatId);
|
|
await reconcileLastMessage(accountId, chatId);
|
|
}
|
|
return null;
|
|
} on PacketError catch (e) {
|
|
logger.w('clearHistory $chatId: ${e.message}');
|
|
return e.message;
|
|
} catch (e) {
|
|
logger.w('clearHistory $chatId: $e');
|
|
return 'Не удалось очистить историю';
|
|
}
|
|
}
|
|
|
|
static Future<bool> leaveChat(Api api, {required int chatId}) async {
|
|
try {
|
|
await api.sendRequest(Opcode.chatLeave, {'chatId': chatId});
|
|
final accountId = await TokenStorage.getActiveAccountId();
|
|
if (accountId != null) {
|
|
await AppDatabase.deleteChat(chatId, accountId);
|
|
_bump();
|
|
}
|
|
return true;
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
static Future<List<CachedChat>> refreshChats(
|
|
Api api,
|
|
List<int> chatIds,
|
|
) async {
|
|
if (chatIds.isEmpty) return const [];
|
|
try {
|
|
final packet = await api.sendRequest(Opcode.chatInfo, {
|
|
'chatIds': chatIds,
|
|
});
|
|
final payload = packet.payload;
|
|
if (payload is! Map) return const [];
|
|
final list = payload['chats'];
|
|
if (list is! List) return const [];
|
|
final accountId = await TokenStorage.getActiveAccountId();
|
|
if (accountId == null) return const [];
|
|
final existingRows = await AppDatabase.loadChatsByIds(accountId, chatIds);
|
|
final preloadedExisting = {
|
|
for (final row in existingRows)
|
|
row['id'] as int: CachedChat.fromDbRow(row),
|
|
};
|
|
final out = <CachedChat>[];
|
|
for (final c in list) {
|
|
if (c is Map) {
|
|
final cached = await cacheServerChat(
|
|
c,
|
|
accountId,
|
|
preloadedExisting: preloadedExisting,
|
|
);
|
|
if (cached != null) out.add(cached);
|
|
}
|
|
}
|
|
return out;
|
|
} on PacketError catch (e) {
|
|
logger.w('refreshChats: ${e.message}');
|
|
return const [];
|
|
} catch (e) {
|
|
logger.w('refreshChats: $e');
|
|
return const [];
|
|
}
|
|
}
|
|
}
|