Files
Qlyra/lib/backend/modules/chats.dart
T

2067 lines
62 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 'shared_content.dart';
import '../../core/storage/app_database.dart';
import '../../core/storage/chat_members_store.dart';
import '../../core/storage/token_storage.dart';
import '../../core/utils/logger.dart';
import '../../core/utils/text_format.dart';
import '../../models/chat_preview_media.dart';
import '../../models/contact_info.dart';
import '../api.dart';
import 'chat_parsing.dart';
import 'chat_preview.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 {};
}
/// Server message ids carry their timestamp in the high bits: the low 16 bits
/// are an intra-millisecond sequence number.
int messageIdToTime(int messageId) => messageId >> 16;
bool messageMentionsUser(Map<dynamic, dynamic> message, int userId) {
final elements = message['elements'];
if (elements is! List) return false;
for (final element in elements.whereType<Map>()) {
if (element['type']?.toString() != 'USER_MENTION') continue;
if (element['entityId'] == userId) return true;
}
return false;
}
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 String? lastMsgPreview;
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;
final int? pinnedMsgId;
final String? pinnedMsgText;
final int? pinnedMsgTime;
final bool pinnedMsgIsPreview;
final int? lastMentionMsgId;
CachedChat({
required this.id,
required this.accountId,
required this.type,
this.title,
this.iconUrl,
this.lastMsgId,
this.lastMsgTime,
this.lastMsgText,
this.lastMsgElements,
this.lastMsgPreview,
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 {},
this.pinnedMsgId,
this.pinnedMsgText,
this.pinnedMsgTime,
this.pinnedMsgIsPreview = false,
this.lastMentionMsgId,
}) : lastMsgTextOneLine = lastMsgText != null && lastMsgText.contains('\n')
? lastMsgText.replaceAll('\n', ' ')
: lastMsgText;
bool get isOfficial => options.contains('OFFICIAL');
late final ChatPreviewMedia? lastMsgMedia = ChatPreviewMedia.decode(
lastMsgPreview,
);
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 hasPinnedMessage => pinnedMsgId != null;
bool get isGroupChat => type == 'CHAT' || type == 'GROUP';
bool canPinMessages(int myId) {
if (!isGroupChat) return false;
return iAmAdmin(myId) || options.contains('ALL_CAN_PIN_MESSAGE');
}
bool get forwardDisabled => options.contains('DISABLE_FORWARD');
bool get copyDisabled => options.contains('MESSAGE_COPY_NOT_ALLOWED');
bool get confirmBeforeSend => options.contains('CONFIRM_BEFORE_SEND');
bool get isMuted {
if (dontDisturbUntil == ChatsModule.muteOff) return false;
if (dontDisturbUntil < 0) return true;
return dontDisturbUntil > DateTime.now().millisecondsSinceEpoch;
}
bool get isLastMsgDeleted => lastMsgText == ChatsModule.lastMsgPlaceholder;
bool get hasUnreadMention {
final mentionId = lastMentionMsgId;
if (mentionId == null || mentionId <= 0) return false;
return messageIdToTime(mentionId) > (participants[accountId] ?? 0);
}
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?,
lastMsgPreview: row['last_msg_preview'] 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']),
pinnedMsgId: row['pinned_msg_id'] as int?,
pinnedMsgText: row['pinned_msg_text'] as String?,
pinnedMsgTime: row['pinned_msg_time'] as int?,
pinnedMsgIsPreview: (row['pinned_msg_is_preview'] as int? ?? 0) == 1,
lastMentionMsgId: row['last_mention_msg_id'] as int?,
);
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_preview': lastMsgPreview,
'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(','),
'pinned_msg_id': pinnedMsgId,
'pinned_msg_text': pinnedMsgText,
'pinned_msg_time': pinnedMsgTime,
'pinned_msg_is_preview': pinnedMsgIsPreview ? 1 : 0,
'last_mention_msg_id': lastMentionMsgId,
};
static const Object _keep = Object();
CachedChat copyWith({
String? type,
Object? title = _keep,
Object? iconUrl = _keep,
Object? lastMsgId = _keep,
Object? lastMsgTime = _keep,
Object? lastMsgText = _keep,
Object? lastMsgElements = _keep,
Object? lastMsgPreview = _keep,
Object? lastMsgSenderId = _keep,
Object? lastMsgStatus = _keep,
int? unreadCount,
int? lastEventTime,
int? cachedAt,
Object? favIndex = _keep,
int? dontDisturbUntil,
bool? isOnline,
int? seenTime,
Map<int, int>? participants,
Set<String>? options,
Object? owner = _keep,
Set<int>? admins,
Object? pinnedMsgId = _keep,
Object? pinnedMsgText = _keep,
Object? pinnedMsgTime = _keep,
bool? pinnedMsgIsPreview,
Object? lastMentionMsgId = _keep,
}) {
return CachedChat(
id: id,
accountId: accountId,
type: type ?? this.type,
title: identical(title, _keep) ? this.title : title as String?,
iconUrl: identical(iconUrl, _keep) ? this.iconUrl : iconUrl as String?,
lastMsgId: identical(lastMsgId, _keep)
? this.lastMsgId
: lastMsgId as int?,
lastMsgTime: identical(lastMsgTime, _keep)
? this.lastMsgTime
: lastMsgTime as int?,
lastMsgText: identical(lastMsgText, _keep)
? this.lastMsgText
: lastMsgText as String?,
lastMsgElements: identical(lastMsgElements, _keep)
? this.lastMsgElements
: lastMsgElements as String?,
lastMsgPreview: identical(lastMsgPreview, _keep)
? this.lastMsgPreview
: lastMsgPreview as String?,
lastMsgSenderId: identical(lastMsgSenderId, _keep)
? this.lastMsgSenderId
: lastMsgSenderId as int?,
lastMsgStatus: identical(lastMsgStatus, _keep)
? this.lastMsgStatus
: lastMsgStatus as String?,
unreadCount: unreadCount ?? this.unreadCount,
lastEventTime: lastEventTime ?? this.lastEventTime,
cachedAt: cachedAt ?? this.cachedAt,
favIndex: identical(favIndex, _keep) ? this.favIndex : favIndex as int?,
dontDisturbUntil: dontDisturbUntil ?? this.dontDisturbUntil,
isOnline: isOnline ?? this.isOnline,
seenTime: seenTime ?? this.seenTime,
participants: participants ?? this.participants,
options: options ?? this.options,
owner: identical(owner, _keep) ? this.owner : owner as int?,
admins: admins ?? this.admins,
pinnedMsgId: identical(pinnedMsgId, _keep)
? this.pinnedMsgId
: pinnedMsgId as int?,
pinnedMsgText: identical(pinnedMsgText, _keep)
? this.pinnedMsgText
: pinnedMsgText as String?,
pinnedMsgTime: identical(pinnedMsgTime, _keep)
? this.pinnedMsgTime
: pinnedMsgTime as int?,
pinnedMsgIsPreview: pinnedMsgIsPreview ?? this.pinnedMsgIsPreview,
lastMentionMsgId: identical(lastMentionMsgId, _keep)
? this.lastMentionMsgId
: lastMentionMsgId as int?,
);
}
}
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 ChatMemberEntry {
final int id;
final String? name;
final String? fullName;
final String? avatarUrl;
final int? seenTime;
final int presenceStatus;
final bool blocked;
final bool isContact;
const ChatMemberEntry({
required this.id,
this.name,
this.fullName,
this.avatarUrl,
this.seenTime,
required this.presenceStatus,
this.blocked = false,
this.isContact = false,
});
bool get isOnline => presenceStatus == 1;
}
class ChatMembersPage {
final List<ChatMemberEntry> members;
final int marker;
const ChatMembersPage({required this.members, required this.marker});
}
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__';
ChatsModule._();
final _messageEventsController = StreamController<MessageEvent>.broadcast();
Stream<MessageEvent> get messageEvents => _messageEventsController.stream;
int? _paginatedAccountId;
void emitMessageSent(int chatId, String tempId, CachedMessage message) {
_messageEventsController.add(MessageSentEvent(chatId, tempId, message));
}
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,
}, silent: true);
} catch (_) {}
}
final cached = CachedChat.fromDbRow(row);
final currentMark = cached.participants[accountId] ?? 0;
final participants = Map<int, int>.from(cached.participants)
..[accountId] = mark > currentMark ? mark : currentMark;
final updated = cached.copyWith(unreadCount: 0, participants: participants);
await AppDatabase.saveChats([updated.toDbRow()]);
_bump();
}
Future<void> markReadUpTo(
Api api,
int accountId,
int chatId,
String messageId,
int mark, {
required int remaining,
}) async {
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,
}, silent: true);
} catch (_) {}
}
final rows = await AppDatabase.loadChat(accountId, chatId);
if (rows.isEmpty) return;
final cached = CachedChat.fromDbRow(rows.first);
final next = remaining < 0 ? 0 : remaining;
final currentMark = cached.participants[accountId] ?? 0;
final nextMark = mark > currentMark ? mark : currentMark;
if (cached.unreadCount == next && nextMark == currentMark) return;
final participants = Map<int, int>.from(cached.participants)
..[accountId] = nextMark;
final updated = cached.copyWith(
unreadCount: next,
participants: participants,
);
await AppDatabase.saveChats([updated.toDbRow()]);
_bump();
}
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;
await _updateChat(accountId, chatId, (chat) {
final participants = Map<int, int>.from(chat.participants)
..[accountId] = mark - 1;
return chat.copyWith(unreadCount: unread, participants: participants);
});
return unread;
}
Future<void> applyOutgoing(
int accountId,
int chatId, {
required String messageId,
required int time,
required String text,
required String status,
List<Map<String, dynamic>>? elements,
String? preview,
}) async {
final thisId = int.tryParse(messageId);
await _updateChat(accountId, chatId, (chat) {
final existingTime = chat.lastMsgTime ?? 0;
if (time < existingTime && chat.lastMsgId != thisId) return null;
return chat.copyWith(
lastMsgId: thisId,
lastMsgText: text,
lastMsgElements: (elements != null && elements.isNotEmpty)
? jsonEncode(elements)
: null,
lastMsgPreview: preview,
lastMsgTime: time,
lastEventTime: time,
lastMsgSenderId: accountId,
lastMsgStatus: status,
);
});
}
final ValueNotifier<int> chatsChanged = ValueNotifier(0);
void _bump() => chatsChanged.value = chatsChanged.value + 1;
static const Set<String> _membershipEvents = {
'add',
'joinByLink',
'leave',
'remove',
};
void _applyMembershipControl(
int accountId,
int chatId,
CachedMessage message,
) {
final control = message.controlAttachment;
final event = control?.event;
if (control == null || event == null) return;
if (!_membershipEvents.contains(event)) return;
if (message.senderId != accountId) {
final affected = control.userIds?.length ?? 1;
ChatMembersStore.instance.adjust(chatId, switch (event) {
'add' => affected,
'joinByLink' => 1,
'leave' => -1,
'remove' => -affected,
_ => 0,
});
}
_refreshChatInfo(chatId);
}
void _refreshChatInfo(int chatId) {
ChatInfoFetch.invalidate(chatId);
unawaited(ChatInfoFetch.get(chatId));
}
Future<bool> _updateChat(
int accountId,
int chatId,
CachedChat? Function(CachedChat chat) mutate,
) async {
final rows = await AppDatabase.loadChat(accountId, chatId);
if (rows.isEmpty) return false;
final updated = mutate(CachedChat.fromDbRow(rows.first));
if (updated == null) return false;
final row = Map<String, dynamic>.from(rows.first)
..addAll(updated.toDbRow());
await AppDatabase.saveChats([row]);
_bump();
return true;
}
static Map<String, dynamic>? _decodePayload(dynamic raw) {
if (raw is String && raw.isNotEmpty) {
try {
return Map<String, dynamic>.from(jsonDecode(raw) as Map);
} catch (_) {}
}
return null;
}
StreamSubscription<Packet>? _globalPushSub;
StreamSubscription<SessionState>? _globalStateSub;
Future<void> _pushQueue = Future.value();
final Set<int> _historyFetched = {};
bool wasHistoryFetched(int chatId) => _historyFetched.contains(chatId);
void markHistoryFetched(int chatId) => _historyFetched.add(chatId);
void attachGlobalPushHandlers(Api api) {
_globalPushSub?.cancel();
_globalStateSub?.cancel();
_globalPushSub = api.pushStream.listen(_enqueueGlobalPush);
_globalStateSub = api.stateStream.listen(_handleSessionState);
}
void dispose() {
_globalPushSub?.cancel();
_globalStateSub?.cancel();
_globalPushSub = null;
_globalStateSub = null;
_contactFlushTimer?.cancel();
_contactFlushTimer = null;
_messageEventsController.close();
chatsChanged.dispose();
}
void _handleSessionState(SessionState state) {
if (state == SessionState.disconnected) {
ContactInfoFetch.clear();
PresenceFetch.clear();
ChatInfoFetch.clear();
_historyFetched.clear();
}
}
void resetForAccountSwitch() {
_historyFetched.clear();
ContactInfoFetch.clear();
PresenceFetch.clear();
ChatInfoFetch.clear();
SharedContentModule.clearMediaIndex();
}
void _enqueueGlobalPush(Packet packet) {
_pushQueue = _pushQueue.then((_) => _handleGlobalPush(packet)).catchError((
Object e,
) {
logger.w('Ошибка обработки пуша: $e');
});
}
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);
}
}
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));
}
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();
}
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 msgLink = msg['link'];
final linkPostId = (msgLink is Map) ? msgLink['postId'] : null;
final payloadPostId = payload['postId'];
final isCommentPush =
payloadPostId is String ||
(linkPostId is String) ||
(msg['postId'] is String);
if (isCommentPush) 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.raw, 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) {
final mergedPayload =
_decodePayload(existing['payload']) ??
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;
_applyMembershipControl(accountId, chatId, 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);
newRow['last_msg_preview'] = messagePreviewMedia(msg);
if (senderId != null) newRow['last_msg_sender'] = senderId;
newRow['last_msg_status'] = 'sent';
}
if (unread != null) newRow['unread_count'] = unread;
if (msgIdInt != null &&
status != 'REMOVED' &&
senderId != accountId &&
messageMentionsUser(msg, accountId)) {
newRow['last_mention_msg_id'] = msgIdInt;
}
final pinned = _extractPinnedMessage(msg);
if (pinned != null) {
newRow['pinned_msg_id'] = pinned.id;
newRow['pinned_msg_text'] = pinned.text;
newRow['pinned_msg_time'] = pinned.time;
newRow['pinned_msg_is_preview'] = pinned.isPreview ? 1 : 0;
}
await AppDatabase.saveChats([newRow]);
_bump();
}
({int? id, String? text, int? time, bool isPreview})? _extractPinnedMessage(
Map msg,
) {
final attaches = msg['attaches'];
if (attaches is! List) return null;
for (final a in attaches.whereType<Map>()) {
if ((a['_type'] as String?) != 'CONTROL') continue;
final event = a['event']?.toString();
if (event != 'pin' && event != 'unpin') continue;
final pinned = a['pinnedMessage'];
if (event == 'unpin' || pinned is! Map) {
return (id: null, text: null, time: null, isPreview: false);
}
final rawId = pinned['id'];
final id = rawId is int ? rawId : int.tryParse(rawId?.toString() ?? '');
if (id == null) return null;
final preview = pinnedMessagePreview(pinned.cast<dynamic, dynamic>());
return (
id: id,
text: preview.text,
time: pinned['time'] as int?,
isPreview: preview.isPreview,
);
}
return null;
}
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;
String? previewMedia;
final payload = _decodePayload(m['payload']);
if (payload != null) previewMedia = messagePreviewMedia(payload);
if (rawText == null || rawText.isEmpty) {
if (payload != null) previewText = messagePreviewText(payload);
} else {
if (payload != null) elementsJson = messagePreviewElements(payload);
}
newRow['last_msg_id'] = int.tryParse(m['id']?.toString() ?? '');
newRow['last_msg_text'] = previewText ?? m['text'];
newRow['last_msg_elements'] = elementsJson;
newRow['last_msg_preview'] = previewMedia;
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_preview'] = null;
newRow['last_msg_sender'] = null;
newRow['last_msg_status'] = null;
}
if (unread != null) newRow['unread_count'] = unread;
await AppDatabase.saveChats([newRow]);
}
/// Вызывается после успешного фетча истории чата —
/// если в превью был placeholder, заменяем его на актуальное
/// последнее сообщение из кеша.
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.isLastMsgDeleted) return;
await _reconcileLastMessage(accountId, chatId, rows.first);
_bump();
}
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();
}
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;
}
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;
final payloadMap =
_decodePayload(existing['payload']) ?? <String, dynamic>{};
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();
}
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();
}
final Set<int> _pendingContactUpdates = {};
Timer? _contactFlushTimer;
Future<void>? _contactFlushFuture;
static const _contactFlushDelay = Duration(milliseconds: 250);
void applyContactUpdate(int contactId) {
_pendingContactUpdates.add(contactId);
if (_contactFlushTimer != null) return;
if (_contactFlushFuture != null) return;
_contactFlushTimer = Timer(_contactFlushDelay, _kickFlush);
}
void _kickFlush() {
_contactFlushTimer = null;
if (_contactFlushFuture != null) return;
_contactFlushFuture = _flushContactUpdates().whenComplete(() {
_contactFlushFuture = null;
if (_pendingContactUpdates.isNotEmpty) {
_contactFlushTimer ??= Timer(_contactFlushDelay, _kickFlush);
}
});
}
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();
}
}
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'];
ChatMembersStore.instance.applyChatPayload(chat);
Map<int, CachedChat> existing = const {};
Map<String, dynamic>? existingRow;
if (preloadedExisting != null) {
existing = preloadedExisting;
} else if (id is int) {
final rows = await AppDatabase.loadChat(accountId, id);
if (rows.isNotEmpty) {
existingRow = rows.first;
existing = {id: CachedChat.fromDbRow(existingRow)};
}
}
final parsed = parseChatRow(
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];
final listState = !inList ? 0 : (chat['status'] == 'HIDDEN' ? 2 : 1);
final membershipUnchanged =
existingRow == null || existingRow['in_list'] == listState;
if (ex != null && sameChatContent(ex, parsed) && membershipUnchanged) {
return parsed;
}
final row = parsed.toDbRow();
row['in_list'] = listState;
await AppDatabase.saveChats([row]);
_bump();
return parsed;
}
/// Парсит и кэширует чаты из payload opcode 19.
///
/// Для диалогов разрезолвит имя и аватар из списка [contacts] того же
/// ответа. На warm start контакты не приходят — используется существующий
/// кэш.
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);
await _persistChatMaps(
chats,
accountId,
currentUserId,
contactsMap: contactsMap,
chatsConfig: chatsConfig,
presenceMap: presenceMap,
);
} catch (e) {
logger.e("Ошибка при синке: $e");
}
}
Future<int> _persistChatMaps(
List<dynamic> chats,
int accountId,
int currentUserId, {
Map<int, Map<dynamic, dynamic>> contactsMap = const {},
Map<dynamic, dynamic> chatsConfig = const {},
Map<dynamic, dynamic> presenceMap = const {},
}) async {
final cachedAt = DateTime.now().millisecondsSinceEpoch;
final existingRows = await AppDatabase.loadChats(
accountId,
includeHidden: true,
);
final existing = {
for (final row in existingRows)
row['id'] as int: CachedChat.fromDbRow(row),
};
final rows = <Map<String, dynamic>>[];
for (final c in chats.whereType<Map>()) {
final map = c.cast<dynamic, dynamic>();
ChatMembersStore.instance.applyChatPayload(map);
final parsed = parseChatRow(
map,
accountId,
currentUserId,
contactsMap,
chatsConfig,
presenceMap,
existing,
cachedAt,
);
if (parsed == null) continue;
final row = parsed.toDbRow();
row['in_list'] = map['status'] == 'HIDDEN' ? 2 : 1;
rows.add(row);
}
if (rows.isNotEmpty) {
await AppDatabase.saveChats(rows);
_bump();
}
return rows.length;
}
Future<void> paginateChats(
Api api,
int accountId,
int currentUserId,
Map<dynamic, dynamic> loginData,
) async {
if (_paginatedAccountId == accountId) return;
_paginatedAccountId = accountId;
try {
var marker = loginData['chatMarker'];
if (marker is! int || marker <= 0) return;
const count = 50;
var page = 0;
while (page < 200) {
page++;
final resp = await api.sendRequestMap(Opcode.chatsList, {
'marker': marker,
'count': count,
});
if (resp == null) break;
final chats = resp['chats'];
if (chats is! List || chats.isEmpty) break;
await _persistChatMaps(chats, accountId, currentUserId);
final next = resp['marker'];
if (next is! int || next == marker || chats.length < count) break;
marker = next;
}
await applyFavorites(accountId);
} catch (e) {
_paginatedAccountId = null;
logger.w('Пагинация чатов: $e');
}
}
final Set<int> _repairedSenders = {};
Future<List<CachedChat>> getChats(
int accountId, {
bool includeHidden = false,
}) async {
try {
if (_repairedSenders.add(accountId)) {
await AppDatabase.repairLastMessageSenders(accountId);
}
final rows = await AppDatabase.loadChats(
accountId,
includeHidden: includeHidden,
);
final chats = rows.map(CachedChat.fromDbRow).toList();
return chats;
} catch (e) {
logger.e("Ошибка при получении чатов: $e");
return [];
}
}
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 [];
}
}
Future<void> clearCache(int accountId) =>
AppDatabase.clearChatsCache(accountId);
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;
final info = Map<String, dynamic>.from(chats.first as Map);
ChatMembersStore.instance.applyChatPayload(info);
return info;
}
Future<Map<int, int>> getReadMarks(Api api, int accountId, int chatId) async {
try {
final info = await getChatInfo(api, chatId);
final fresh = parseParticipants(info?['participants']);
if (fresh.isNotEmpty) return fresh;
} catch (e) {
logger.w('Не удалось получить отметки прочтения для $chatId: $e');
}
final rows = await getChat(accountId, chatId);
return rows.isEmpty ? const {} : rows.first.participants;
}
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;
}
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 [];
}
}
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 [];
}
}
Future<void> subscribeChat(
Api api,
int chatId, {
bool subscribe = true,
}) async {
try {
await api.sendRequest(Opcode.chatSubscribe, {
'chatId': chatId,
'subscribe': subscribe,
}, silent: true);
} catch (e) {
logger.w('subscribeChat failed: $e');
}
}
Future<({CachedChat chat, int? subscribersCount})> joinChannel(
Api api,
String link,
int accountId,
) async {
final packet = await api.sendRequest(Opcode.chatJoin, {
'link': link,
}, silent: true);
if (!packet.isOk) {
throw PacketError(messageFromErrorPayload(packet.payload));
}
final payload = packet.payload;
final chatMap = payload is Map ? payload['chat'] : null;
if (chatMap is! Map) {
throw const PacketError('Не удалось подписаться');
}
final cached = await cacheServerChat(chatMap, accountId);
if (cached == null) {
throw const PacketError('Не удалось подписаться');
}
final count = chatMap['participantsCount'];
if (count is! int) ChatMembersStore.instance.adjust(cached.id, 1);
_refreshChatInfo(cached.id);
return (chat: cached, subscribersCount: count is int ? count : null);
}
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;
}
}
Future<CachedChat?> createGroupChat(
Api api, {
required String title,
required List<int> userIds,
bool notify = true,
}) => _createChat(
api,
chatType: 'CHAT',
title: title,
userIds: userIds,
notify: notify,
);
Future<CachedChat?> createChannel(
Api api, {
required String title,
List<int> userIds = const [],
bool notify = true,
}) => _createChat(
api,
chatType: 'CHANNEL',
title: title,
userIds: userIds,
notify: notify,
);
Future<CachedChat?> _createChat(
Api api, {
required String chatType,
required String title,
required List<int> userIds,
required bool notify,
}) async {
final payload = {
'message': {
'cid': DateTime.now().millisecondsSinceEpoch,
'attaches': [
{
'_type': 'CONTROL',
'event': 'new',
'chatType': chatType,
'title': title,
'userIds': userIds,
},
],
},
'notify': notify,
};
final packet = await api.sendRequest(Opcode.msgSend, payload);
if (!packet.isOk) {
logger.w(
'_createChat($chatType): server error payload=${packet.payload}',
);
return null;
}
final data = packet.payload;
if (data is! Map) {
logger.w('_createChat($chatType): payload is not a Map: $data');
return null;
}
final chat = data['chat'];
if (chat is! Map) {
logger.w('_createChat($chatType): response has no chat field: $data');
return null;
}
final accountId = await TokenStorage.getActiveAccountId();
if (accountId == null) {
logger.w('_createChat($chatType): no active account id');
return null;
}
return cacheServerChat(chat, accountId);
}
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?;
}
Future<bool> setChatPhoto(
Api api, {
required int chatId,
required String photoToken,
}) async {
return api.sendRequestOk(Opcode.chatUpdate, {
'chatId': chatId,
'photoToken': photoToken,
});
}
Future<bool> setChatOptions(
Api api, {
required int chatId,
required Map<String, dynamic> options,
}) async {
return api.sendRequestOk(Opcode.chatUpdate, {
'chatId': chatId,
'options': options,
});
}
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;
await _updateChat(accountId, chatId, (chat) => chat.copyWith(title: title));
return true;
}
Future<String?> setPinnedMessage(
Api api, {
required int chatId,
required int? messageId,
bool notify = true,
}) async {
try {
final packet = await api.sendRequest(Opcode.chatUpdate, {
'chatId': chatId,
'notifyPin': notify,
'pinMessageId': messageId ?? 0,
});
if (!packet.isOk) {
return messageFromErrorPayload(packet.payload);
}
final data = packet.payload;
final chat = data is Map ? data['chat'] : null;
if (chat is Map) {
final accountId = await TokenStorage.getActiveAccountId();
if (accountId != null) {
await cacheServerChat(chat.cast<dynamic, dynamic>(), accountId);
}
}
return null;
} on PacketError catch (e) {
logger.w('setPinnedMessage $chatId: ${e.message}');
return e.message;
} catch (e) {
logger.w('setPinnedMessage $chatId: $e');
return 'Не удалось изменить закрепление';
}
}
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);
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 'Не удалось изменить закрепление';
}
}
Future<void> applyFavorites(int accountId) async {
try {
final folders = await FoldersModule.loadFolders(accountId);
if (folders.isEmpty) return;
final allFolder = folders.firstWhere(
FoldersModule.isAllChatsFolder,
orElse: () => folders.first,
);
final favorites = allFolder.favorites;
final favIndexById = <int, int>{};
for (var i = 0; i < favorites.length; i++) {
favIndexById[favorites[i]] = i + 1;
}
final rows = await AppDatabase.loadChats(accountId, includeHidden: true);
final updates = <Map<String, dynamic>>[];
for (final row in rows) {
final id = row['id'] as int;
final current = (row['fav_index'] as int?) ?? 0;
final next = favIndexById[id] ?? 0;
if (current == next) continue;
final newRow = Map<String, dynamic>.from(row);
newRow['fav_index'] = next;
updates.add(newRow);
}
if (updates.isNotEmpty) {
await AppDatabase.saveChats(updates);
_bump();
}
} catch (e) {
logger.w('applyFavorites: $e');
}
}
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) {
await _updateChat(
accountId,
chatId,
(chat) => chat.copyWith(dontDisturbUntil: dontDisturbUntil),
);
}
return null;
} on PacketError catch (e) {
logger.w('setChatMute $chatId: ${e.message}');
return e.message;
} catch (e) {
logger.w('setChatMute $chatId: $e');
return 'Не удалось изменить уведомления';
}
}
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 'Не удалось удалить чат';
}
}
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 'Не удалось очистить историю';
}
}
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;
}
}
Future<bool> addMembers(
Api api, {
required int chatId,
required List<int> userIds,
bool showHistory = true,
}) async {
if (userIds.isEmpty) return false;
try {
final packet = await api.sendRequest(Opcode.chatMembersUpdate, {
'chatId': chatId,
'userIds': userIds,
'showHistory': showHistory,
'operation': 'add',
});
if (!packet.isOk) {
logger.w(
'addMembers $chatId: ${messageFromErrorPayload(packet.payload)}',
);
return false;
}
final data = packet.payload;
final chat = data is Map ? data['chat'] : null;
if (chat is Map) {
final accountId = await TokenStorage.getActiveAccountId();
if (accountId != null) {
await cacheServerChat(chat.cast<dynamic, dynamic>(), accountId);
}
}
if (chat is! Map || chat['participantsCount'] is! int) {
ChatMembersStore.instance.adjust(chatId, userIds.length);
}
_refreshChatInfo(chatId);
return true;
} on PacketError catch (e) {
logger.w('addMembers $chatId: ${e.message}');
return false;
} catch (e) {
logger.w('addMembers $chatId: $e');
return false;
}
}
Future<ChatMembersPage?> getChatMembers(
Api api,
int chatId, {
int marker = 0,
int count = 50,
}) async {
try {
final packet = await api.sendRequest(Opcode.chatMembers, {
'type': 'MEMBER',
'marker': marker,
'chatId': chatId,
'count': count,
});
if (!packet.isOk) return null;
final payload = packet.payload;
if (payload is! Map) return null;
final entries = <ChatMemberEntry>[];
final presenceById = <int, Map<String, dynamic>>{};
final rawMembers = payload['members'];
if (rawMembers is List) {
for (final m in rawMembers.whereType<Map>()) {
final contact = m['contact'];
if (contact is! Map) continue;
final id = contact['id'];
if (id is! int) continue;
final info = ContactInfo.fromMap(Map<String, dynamic>.from(contact));
final name = info.displayName;
if (name != null && name.isNotEmpty) ContactCache.put(id, name);
final avatar = info.avatarUrl;
if (avatar != null && avatar.isNotEmpty) {
ContactCache.putAvatar(id, avatar);
}
final phone = contact['phone'];
if (phone is int && phone > 0) ContactCache.putPhone(id, phone);
ContactInfoFetch.putContact(id, contact.cast<dynamic, dynamic>());
final presence = m['presence'];
var status = 0;
int? seen;
if (presence is Map) {
final p = Map<String, dynamic>.from(presence);
presenceById[id] = p;
status = (p['status'] as int?) ?? 0;
final s = p['seen'];
seen = s is int ? s : null;
}
entries.add(
ChatMemberEntry(
id: id,
name: name,
fullName: info.fullName,
avatarUrl: avatar,
seenTime: seen,
presenceStatus: status,
blocked: info.isDeleted,
isContact: info.isSavedContact,
),
);
}
}
if (presenceById.isNotEmpty) PresenceFetch.primeAll(presenceById);
final next = payload['marker'];
return ChatMembersPage(
members: entries,
marker: next is int ? next : marker,
);
} on PacketError catch (e) {
logger.w('getChatMembers $chatId: ${e.message}');
return null;
} catch (e) {
logger.w('getChatMembers $chatId: $e');
return null;
}
}
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 [];
}
}
}
final chats = ChatsModule._();