Files
2026-08-10 15:08:06 +07:00

536 lines
15 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:flutter/foundation.dart';
import '../api.dart';
import '../models/chat_folder.dart';
import 'chats.dart';
import '../../core/protocol/opcode_map.dart';
import '../../core/protocol/packet.dart';
import '../../core/storage/app_database.dart';
import '../../core/storage/token_storage.dart';
class _FoldersSnapshot {
final List<ChatFolder> folders;
final List<String> order;
final int folderSync;
const _FoldersSnapshot({
this.folders = const [],
this.order = const [],
this.folderSync = 0,
});
}
class FoldersModule {
static const _syncKey = 'chat_folders_snapshot';
static const _listReadyKey = 'chat_folders_list_ready';
static const String allChatsFolderId = 'all.chat.folder';
static const int titleMaxLength = 20;
static final ValueNotifier<int> revision = ValueNotifier<int>(0);
static StreamSubscription<Packet>? _pushSub;
static Future<void> _pushQueue = Future.value();
static void attachGlobalPushHandlers(Api api) {
_pushSub?.cancel();
_pushSub = api.pushStream
.where((p) => p.opcode == Opcode.notifFolders)
.listen(_enqueuePush);
}
static void _enqueuePush(Packet packet) {
_pushQueue = _pushQueue
.then((_) => _handleFoldersPush(packet))
.catchError((Object _) {});
}
static Future<void> _handleFoldersPush(Packet packet) async {
final payload = packet.payload;
if (payload is! Map) return;
final accountId = await TokenStorage.getActiveAccountId();
if (accountId == null) return;
await applyPayload(accountId, payload.cast<dynamic, dynamic>());
await chats.applyFavorites(accountId);
}
static Future<void> markFoldersListReady(int accountId) async {
await AppDatabase.setSyncValue(accountId, _listReadyKey, '1');
}
static Future<bool> hasReceivedFoldersList(int accountId) async {
final ready = await AppDatabase.getSyncValue(accountId, _listReadyKey);
if (ready == '1') return true;
final snap = await AppDatabase.getSyncValue(accountId, _syncKey);
return snap != null && snap.isNotEmpty;
}
static bool isAllChatsFolder(ChatFolder f) {
if (f.id == allChatsFolderId) return true;
final t = f.title.trim().toLowerCase();
return t == 'все' || t == 'все чаты' || t == 'all' || t == 'all chats';
}
static String? preferredInitialFolderId(List<ChatFolder> folders) {
if (folders.isEmpty) return null;
for (final f in folders) {
if (isAllChatsFolder(f)) return f.id;
}
return folders.first.id;
}
static String newFolderId() {
final random = Random.secure();
final bytes = List<int>.generate(16, (_) => random.nextInt(256));
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
return '${hex.substring(0, 8)}-${hex.substring(8, 12)}-'
'${hex.substring(12, 16)}-${hex.substring(16, 20)}-${hex.substring(20)}';
}
static void _sortInPlace(List<ChatFolder> folders, List<String> order) {
if (order.isEmpty) return;
final orderIndex = <String, int>{};
for (var i = 0; i < order.length; i++) {
orderIndex.putIfAbsent(order[i], () => i);
}
folders.sort((a, b) {
final aIndex = orderIndex[a.id] ?? -1;
final bIndex = orderIndex[b.id] ?? -1;
if (aIndex == -1 && bIndex == -1) return 0;
if (aIndex == -1) return 1;
if (bIndex == -1) return -1;
return aIndex.compareTo(bIndex);
});
}
static bool _matchesType(
int filter,
CachedChat chat, {
required int myId,
required Set<int> contactIds,
}) {
final isDialog = chat.type == 'DIALOG';
final isBot = isDialog && chat.options.contains('BOT');
final peerId = isDialog ? chat.id ^ myId : null;
final isSelf = peerId != null && peerId == myId;
final isContact =
isDialog && !isSelf && peerId != null && contactIds.contains(peerId);
switch (filter) {
case FolderFilter.channel:
return chat.type == 'CHANNEL';
case FolderFilter.chat:
return chat.type == 'CHAT' || chat.type == 'GROUP';
case FolderFilter.dialog:
return isDialog;
case FolderFilter.contact:
return isDialog && !isBot && isContact;
case FolderFilter.notContact:
return isDialog && !isBot && !isSelf && !isContact;
case FolderFilter.bot:
return isBot;
}
return false;
}
static bool _matchesRole(int filter, CachedChat chat, int myId) {
switch (filter) {
case FolderFilter.owner:
return chat.owner == myId;
case FolderFilter.admin:
return chat.owner == myId || chat.admins.contains(myId);
}
return false;
}
static bool _matchesRestriction(int filter, CachedChat chat) {
switch (filter) {
case FolderFilter.unread:
return chat.unreadCount > 0;
case FolderFilter.read:
return chat.unreadCount == 0;
case FolderFilter.muted:
return chat.isMuted;
case FolderFilter.notMuted:
return !chat.isMuted;
}
return true;
}
static bool chatMatchesFolder(
CachedChat chat,
ChatFolder folder, {
required int myId,
required Set<int> contactIds,
}) {
if (!folder.include.contains(chat.id)) {
final typeFilters = folder.filters
.where(FolderFilter.chatTypes.contains)
.toList();
if (typeFilters.isEmpty) return false;
final matchesType = typeFilters.any(
(f) => _matchesType(f, chat, myId: myId, contactIds: contactIds),
);
if (!matchesType) return false;
}
final roleFilters = folder.filters.where(FolderFilter.roles.contains);
if (roleFilters.isNotEmpty &&
!roleFilters.any((f) => _matchesRole(f, chat, myId))) {
return false;
}
for (final f in folder.filters.where(FolderFilter.showOnly.contains)) {
if (!_matchesRestriction(f, chat)) return false;
}
return true;
}
static List<ChatFolder> _parseFolderList(dynamic raw) {
if (raw is! List) return [];
return raw
.map((e) {
try {
final m = e is Map<String, dynamic>
? e
: Map<String, dynamic>.from(e as Map);
return ChatFolder.fromJson(m);
} catch (_) {
return null;
}
})
.whereType<ChatFolder>()
.toList();
}
static List<String>? _parseOrder(dynamic raw) {
if (raw is! List) return null;
return raw.map((e) => e.toString()).toList();
}
static int? _parseSync(dynamic raw) => raw is int ? raw : null;
static Future<_FoldersSnapshot> _loadSnapshot(int accountId) async {
final raw = await AppDatabase.getSyncValue(accountId, _syncKey);
if (raw == null || raw.isEmpty) return const _FoldersSnapshot();
try {
final map = jsonDecode(raw) as Map<String, dynamic>;
final folders = _parseFolderList(map['folders']);
final order = _parseOrder(map['foldersOrder']) ?? const <String>[];
_sortInPlace(folders, order);
return _FoldersSnapshot(
folders: folders,
order: order,
folderSync: _parseSync(map['folderSync']) ?? 0,
);
} catch (_) {
return const _FoldersSnapshot();
}
}
static Future<void> _saveSnapshot(
int accountId,
_FoldersSnapshot snapshot,
) async {
final known = snapshot.folders.map((f) => f.id).toSet();
final ordered = snapshot.order.where(known.contains).toList();
final orderedSet = ordered.toSet();
final order = [
...ordered,
...known.where((id) => !orderedSet.contains(id)),
];
await AppDatabase.setSyncValue(
accountId,
_syncKey,
jsonEncode({
'folders': snapshot.folders.map((f) => f.toJson()).toList(),
'foldersOrder': order,
'folderSync': snapshot.folderSync,
}),
);
revision.value++;
}
static Future<List<ChatFolder>> loadFolders(int accountId) async {
return (await _loadSnapshot(accountId)).folders;
}
static Future<List<String>> loadFoldersOrder(int accountId) async {
return (await _loadSnapshot(accountId)).order;
}
static Future<int> loadFolderSync(int accountId) async {
return (await _loadSnapshot(accountId)).folderSync;
}
static Future<void> applyPayload(
int accountId,
Map<dynamic, dynamic> payload, {
bool replace = false,
}) async {
final foldersRaw = payload['folders'];
final folderRaw = payload['folder'];
final orderRaw = payload['foldersOrder'];
final syncRaw = payload['folderSync'];
if (foldersRaw == null &&
folderRaw == null &&
orderRaw == null &&
syncRaw == null) {
return;
}
final incoming = <ChatFolder>[
..._parseFolderList(foldersRaw),
if (folderRaw is Map)
ChatFolder.fromJson(Map<String, dynamic>.from(folderRaw)),
];
final current = await _loadSnapshot(accountId);
var folders = replace && foldersRaw is List
? List<ChatFolder>.from(incoming)
: _merge(current.folders, incoming);
final order = _parseOrder(orderRaw) ?? current.order;
if (orderRaw is List && order.isNotEmpty) {
final known = order.toSet();
final fresh = incoming.map((f) => f.id).toSet();
folders = folders
.where((f) => known.contains(f.id) || fresh.contains(f.id))
.toList();
}
_sortInPlace(folders, order);
await _saveSnapshot(
accountId,
_FoldersSnapshot(
folders: folders,
order: order,
folderSync: _parseSync(syncRaw) ?? current.folderSync,
),
);
}
static List<ChatFolder> _merge(
List<ChatFolder> current,
List<ChatFolder> incoming,
) {
final merged = List<ChatFolder>.from(current);
for (final folder in incoming) {
final idx = merged.indexWhere((f) => f.id == folder.id);
if (idx >= 0) {
merged[idx] = folder;
} else {
merged.add(folder);
}
}
return merged;
}
static Future<void> applyFromLoginConfig(
int accountId,
Map<dynamic, dynamic> config,
) async {
final chatFolders = config['chatFolders'];
if (chatFolders is! Map) return;
if (chatFolders['FOLDERS'] == null) return;
await applyPayload(accountId, {
'folders': chatFolders['FOLDERS'],
'foldersOrder': chatFolders['foldersOrder'],
'folderSync': chatFolders['folderSync'],
}, replace: true);
await markFoldersListReady(accountId);
}
static Future<ChatFolder> createFolder(
Api api,
int accountId, {
required String title,
List<int> include = const [],
List<int> filters = const [],
List<int> options = const [],
List<int> favorites = const [],
}) {
return _sendUpdate(
api,
accountId,
id: newFolderId(),
title: title,
include: include,
filters: filters,
options: options,
favorites: favorites,
);
}
static Future<ChatFolder> updateFolder(
Api api,
int accountId,
ChatFolder folder, {
String? title,
List<int>? include,
List<int>? filters,
List<int>? options,
List<int>? favorites,
}) {
return _sendUpdate(
api,
accountId,
id: folder.id,
title: title ?? folder.title,
include: include ?? folder.include,
filters: filters ?? folder.filters,
options: options ?? folder.options,
favorites: favorites ?? folder.favorites,
);
}
static Future<ChatFolder> _sendUpdate(
Api api,
int accountId, {
required String id,
required String title,
required List<int> include,
required List<int> filters,
required List<int> options,
required List<int> favorites,
}) async {
final packet = await api.sendRequest(Opcode.foldersUpdate, {
'id': id,
'title': title.trim(),
'include': include,
'filters': filters,
'options': options,
'favorites': favorites,
});
throwIfPacketError(packet);
final data = packet.payload;
final folderJson = data is Map ? data['folder'] : null;
if (folderJson is! Map) {
throw StateError('FOLDERS_UPDATE: сервер не вернул папку');
}
await applyPayload(accountId, data.cast<dynamic, dynamic>());
return ChatFolder.fromJson(Map<String, dynamic>.from(folderJson));
}
static Future<ChatFolder> setFolderFavorites(
Api api,
int accountId,
ChatFolder folder,
List<int> favorites,
) {
return updateFolder(api, accountId, folder, favorites: favorites);
}
static Future<void> deleteFolders(
Api api,
int accountId,
List<String> folderIds,
) async {
if (folderIds.isEmpty) return;
final packet = await api.sendRequest(Opcode.foldersDelete, {
'folderIds': folderIds,
});
throwIfPacketError(packet);
final removed = folderIds.toSet();
final current = await _loadSnapshot(accountId);
await _saveSnapshot(
accountId,
_FoldersSnapshot(
folders: current.folders.where((f) => !removed.contains(f.id)).toList(),
order: current.order,
folderSync: current.folderSync,
),
);
final data = packet.payload;
if (data is Map) {
await applyPayload(accountId, data.cast<dynamic, dynamic>());
}
}
static Future<void> reorderFolders(
Api api,
int accountId,
List<String> order,
) async {
if (order.isEmpty) return;
final packet = await api.sendRequest(Opcode.foldersReorder, {
'foldersOrder': order,
});
throwIfPacketError(packet);
final current = await _loadSnapshot(accountId);
final folders = List<ChatFolder>.from(current.folders);
_sortInPlace(folders, order);
final data = packet.payload;
await _saveSnapshot(
accountId,
_FoldersSnapshot(
folders: folders,
order: order,
folderSync:
(data is Map ? _parseSync(data['folderSync']) : null) ??
current.folderSync,
),
);
}
static Future<List<ChatFolder>> fetchFoldersByIds(
Api api,
int accountId,
List<String> folderIds,
) async {
if (folderIds.isEmpty) return const [];
final packet = await api.sendRequest(Opcode.foldersGetById, {
'folderIds': folderIds,
});
throwIfPacketError(packet);
final data = packet.payload;
if (data is! Map) return const [];
final folders = _parseFolderList(data['folders']);
final missing = folderIds.toSet()..removeAll(folders.map((f) => f.id));
final current = await _loadSnapshot(accountId);
await _saveSnapshot(
accountId,
_FoldersSnapshot(
folders: _merge(
current.folders.where((f) => !missing.contains(f.id)).toList(),
folders,
),
order: current.order,
folderSync: _parseSync(data['folderSync']) ?? current.folderSync,
),
);
return folders;
}
static Future<void> syncFromServer(Api api, int accountId) async {
try {
final packet = await api.sendRequest(Opcode.foldersGet, {
'folderSync': 0,
});
throwIfPacketError(packet);
final data = packet.payload;
if (data is Map) {
await applyPayload(
accountId,
data.cast<dynamic, dynamic>(),
replace: true,
);
}
} finally {
await markFoldersListReady(accountId);
}
}
}