Build Android (FCM) / build-android-fcm (push) Canceled after 0s
Build Android / build-android (push) Canceled after 0s
Build iOS / build-ios (push) Canceled after 0s
Build Linux / build-linux (push) Canceled after 0s
Build macOS / build-macos (push) Canceled after 0s
Build Windows / build-windows (push) Canceled after 0s
Release (main) / android (oneme) (push) Canceled after 0s
Release (main) / android (qlyra) (push) Canceled after 0s
Release (main) / windows (push) Canceled after 0s
Release (main) / linux (push) Canceled after 0s
Release (main) / macos (push) Canceled after 0s
Release (main) / ios (push) Canceled after 0s
Release (main) / release (push) Canceled after 0s
2067 lines
62 KiB
Dart
2067 lines
62 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
|
|
import '../../core/config/qlyra_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 = '__qlyra_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 && !QlyraSettings.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 && !QlyraSettings.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 = QlyraSettings.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 = QlyraSettings.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 (QlyraSettings.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._();
|