import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:collection/collection.dart'; import '../../../../backend/modules/chats.dart'; import '../../../../backend/modules/messages.dart'; import '../../../../core/cache/message_session_cache.dart'; import '../../../../core/config/qlyra_settings.dart'; import '../../../../core/storage/app_database.dart'; import '../../../../core/utils/logger.dart'; import '../../../../main.dart'; class HistoryGap { HistoryGap({ required this.edgeId, required this.edgeTime, required this.tailTime, }); String edgeId; int edgeTime; final int tailTime; } class ChatController extends ChangeNotifier { static const int historyPageSize = 30; static const int historyInitialLimit = 50; static const int jumpWindowBefore = 40; static const int jumpWindowAfter = 20; static const int historyWalkPageSize = 200; static const int gapPageSize = 60; int chatId = 0; int myId = 0; List messages = []; final ValueNotifier messagesRev = ValueNotifier(0); bool hasMoreHistory = true; bool isLoadingMore = false; bool historyKickedOff = false; bool loadingGap = false; final List gaps = []; bool get hasGap => gaps.isNotEmpty; static bool gapFillLeavesViewportInPlace( HistoryGap gap, int? oldestRenderedTime, ) => oldestRenderedTime != null && oldestRenderedTime >= gap.tailTime; bool Function() isMounted = () => true; void bump() { messagesRev.value++; } int _tempIdCounter = 0; String nextTempId() => 'temp_${++_tempIdCounter}_${DateTime.now().microsecondsSinceEpoch}'; Future persistOutgoing(CachedMessage msg, {String? removeId}) async { try { if (removeId != null && removeId != msg.id) { await AppDatabase.deleteMessage(myId, chatId, removeId); } await AppDatabase.saveMessages([msg.toDbRow()]); } catch (e) { logger.w('Исходящее ${msg.id} не сохранилось: $e'); } } int prependOlder(List olderDesc) { if (olderDesc.isEmpty) return 0; final existing = messages.map((m) => m.id).toSet(); final toAdd = []; for (final m in olderDesc.reversed) { if (existing.add(m.id)) toAdd.add(m); } if (toAdd.isEmpty) return 0; messages = [...toAdd, ...messages]; messagesRev.value++; return toAdd.length; } bool mergeMessages(List decodedDesc) { final byId = {for (final m in messages) m.id: m}; var changed = false; for (final fresh in decodedDesc) { final old = byId[fresh.id]; if (old == null) { byId[fresh.id] = fresh; changed = true; } else if (!_sameMessage(old, fresh)) { byId[fresh.id] = fresh; changed = true; } } if (!changed) return false; final merged = byId.values.toList() ..sort((a, b) { final byTime = a.time.compareTo(b.time); return byTime != 0 ? byTime : a.id.compareTo(b.id); }); messages = merged; messagesRev.value++; return true; } bool _sameMessage(CachedMessage a, CachedMessage b) { return a.id == b.id && a.time == b.time && a.status == b.status && a.text == b.text && a.senderId == b.senderId && a.deleted == b.deleted && a.editHistory?.length == b.editHistory?.length && const DeepCollectionEquality().equals(a.payload, b.payload) && const DeepCollectionEquality().equals( a.attachments?.map((attachment) => attachment.toMap()).toList(), b.attachments?.map((attachment) => attachment.toMap()).toList(), ); } bool latestCovered = false; Future> loadInitialFromDb({ required bool onlyVisible, }) async { final rows = await AppDatabase.loadMessages( myId, chatId, limit: historyInitialLimit, onlyVisible: onlyVisible, ); final ranges = await AppDatabase.loadMessageRanges(myId, chatId); final latest = await CachedMessage.fromDbRowsAsync(rows); latestCovered = latest.isNotEmpty && ranges.coversNewest( latest.map((m) => m.time).reduce((a, b) => a > b ? a : b), ); return ranges.clipLatest(latest, (m) => m.time); } Future> loadOlderFromDb( int beforeTime, bool onlyVisible, { int? limit, }) async { final rows = await AppDatabase.loadMessagesBefore( myId, chatId, beforeTime: beforeTime, limit: limit ?? historyPageSize, onlyVisible: onlyVisible, ); final ranges = await AppDatabase.loadMessageRanges(myId, chatId); return ranges.clipOlder( await CachedMessage.fromDbRowsAsync(rows), beforeTime, (m) => m.time, ); } Future> loadGapSliceFromDb( int afterTime, int beforeTime, bool onlyVisible, ) async { final rows = await AppDatabase.loadMessagesBetween( myId, chatId, afterTime: afterTime, beforeTime: beforeTime, limit: gapPageSize, onlyVisible: onlyVisible, ); return CachedMessage.fromDbRowsAsync(rows); } Future> loadWindowFromDb( int centerTime, bool onlyVisible, ) async { final rows = await AppDatabase.loadMessagesAround( myId, chatId, centerTime: centerTime, before: jumpWindowBefore, after: jumpWindowAfter, onlyVisible: onlyVisible, ); return CachedMessage.fromDbRowsAsync(rows); } Future loadMessageWindow({ required String targetId, required int targetTime, bool Function()? stillWanted, }) async { if (myId == 0 || targetTime <= 0) return false; final onlyVisible = !QlyraSettings.viewDeleted.value; var window = await loadWindowFromDb(targetTime, onlyVisible); if (!isMounted() || stillWanted?.call() == false) return false; if (!window.any((m) => m.id == targetId)) { final fetched = await messagesModule.fetchHistory( myId, chatId, fromTime: targetTime + 1, forward: jumpWindowAfter, backward: jumpWindowBefore + 1, ); if (!isMounted() || stillWanted?.call() == false) return false; if (fetched.isNotEmpty && QlyraSettings.viewDeleted.value) { await chats.reconcileDeletedFromFetch(myId, chatId, fetched); } window = await loadWindowFromDb(targetTime, onlyVisible); if (!isMounted() || stillWanted?.call() == false) return false; } if (window.isEmpty) return false; final oldestLoaded = messages.isEmpty ? 0 : messages.first.time; final reachesLoaded = messages.isEmpty || window.any((m) => m.time >= oldestLoaded); mergeMessages(window); if (reachesLoaded) { persistSessionCache(); } else { _markGapAfterWindow(window); } return messages.any((m) => m.id == targetId); } void _markGapAfterWindow(List window) { var edge = window.first; for (final m in window) { if (m.time > edge.time) edge = m; } final idx = messages.indexWhere((m) => m.id == edge.id); if (idx == -1 || idx + 1 >= messages.length) return; final tailTime = messages[idx + 1].time; gaps.removeWhere((g) => g.tailTime == tailTime); gaps.add( HistoryGap(edgeId: edge.id, edgeTime: edge.time, tailTime: tailTime), ); } void _closeGap(HistoryGap gap) { gaps.remove(gap); if (gaps.isEmpty) persistSessionCache(); } Future fillGapForward( HistoryGap gap, { void Function()? beforeApply, }) async { if (loadingGap || myId == 0 || !gaps.contains(gap)) return 0; if (gap.edgeTime <= 0 || gap.tailTime <= gap.edgeTime) { _closeGap(gap); return 0; } loadingGap = true; try { final onlyVisible = !QlyraSettings.viewDeleted.value; var slice = await loadGapSliceFromDb( gap.edgeTime, gap.tailTime, onlyVisible, ); if (!isMounted()) return 0; if (slice.length < gapPageSize) { final fetched = await messagesModule.fetchHistory( myId, chatId, fromTime: gap.edgeTime, forward: gapPageSize, backward: 0, ); if (!isMounted()) return 0; if (fetched.isNotEmpty && QlyraSettings.viewDeleted.value) { await chats.reconcileDeletedFromFetch(myId, chatId, fetched); } final refreshed = await loadGapSliceFromDb( gap.edgeTime, gap.tailTime, onlyVisible, ); if (!isMounted()) return 0; if (refreshed.length <= slice.length) { if (refreshed.isNotEmpty) { beforeApply?.call(); mergeMessages(refreshed); } _closeGap(gap); return refreshed.length; } slice = refreshed; } if (slice.isEmpty) { _closeGap(gap); return 0; } beforeApply?.call(); mergeMessages(slice); var edge = slice.first; for (final m in slice) { if (m.time > edge.time) edge = m; } gap.edgeId = edge.id; gap.edgeTime = edge.time; if (edge.time >= gap.tailTime) _closeGap(gap); return slice.length; } catch (e) { logger.e('Error filling history gap: $e'); return 0; } finally { loadingGap = false; } } void persistSessionCache() { if (myId == 0 || messages.isEmpty || hasGap) return; MessageSessionCache.save( myId, chatId, messages, reachedStart: !hasMoreHistory, ); } Future loadMoreHistory({ required void Function() onLoadingStarted, required void Function(int added) onLoaded, required void Function(Object error) onError, int? pageSize, bool persist = true, }) async { if (isLoadingMore || !hasMoreHistory || messages.isEmpty) return; isLoadingMore = true; onLoadingStarted(); final size = pageSize ?? historyPageSize; final oldest = messages.first; final onlyVisible = !QlyraSettings.viewDeleted.value; try { var older = await loadOlderFromDb(oldest.time, onlyVisible, limit: size); if (older.length < size) { final fetched = await messagesModule.fetchHistory( myId, chatId, fromTime: oldest.time, count: size, ); if (fetched.isNotEmpty) { if (QlyraSettings.viewDeleted.value) { await chats.reconcileDeletedFromFetch(myId, chatId, fetched); } older = await loadOlderFromDb(oldest.time, onlyVisible, limit: size); } } if (!isMounted()) return; final added = prependOlder(older); isLoadingMore = false; if (added == 0) { final ranges = await AppDatabase.loadMessageRanges(myId, chatId); hasMoreHistory = ranges.at(oldest.time)?.start != 0; } if (persist) persistSessionCache(); onLoaded(added); } catch (e) { logger.e('Error loading more history: $e'); onError(e); } finally { isLoadingMore = false; } } Future loadRemainingHistory({ required void Function(List decoded, {bool markLoaded}) onApplyMerged, required void Function() onLoadingFinished, required void Function() onPreview, required void Function() onSenderNames, }) async { final onlyVisible = !QlyraSettings.viewDeleted.value; final cachedRows = await AppDatabase.loadChat(myId, chatId); final preview = cachedRows.isEmpty || !AppDatabase.chatRowIsInList(cachedRows.first); if (preview) { onPreview(); if (cachedRows.isEmpty) { await chats.ensureChatCached(api, myId, chatId); } await chats.subscribeChat(api, chatId); } final fullDecoded = await loadInitialFromDb(onlyVisible: onlyVisible); if (isMounted()) { onApplyMerged(fullDecoded); } if (fullDecoded.isNotEmpty && latestCovered && chats.wasHistoryFetched(chatId)) { if (isMounted()) { onLoadingFinished(); } onSenderNames(); return; } try { final serverMessages = await messagesModule.fetchHistory(myId, chatId); chats.markHistoryFetched(chatId); if (QlyraSettings.viewDeleted.value) { await chats.reconcileDeletedFromFetch(myId, chatId, serverMessages); } final updatedDecoded = await loadInitialFromDb(onlyVisible: onlyVisible); if (isMounted()) { onApplyMerged(updatedDecoded, markLoaded: true); } unawaited(chats.reconcileLastMessageIfPlaceholder(myId, chatId)); onSenderNames(); } catch (e) { logger.e('Error fetching history: $e'); if (isMounted()) { onLoadingFinished(); } } } @override void dispose() { messagesRev.dispose(); super.dispose(); } }