Files
IKAR/lib/frontend/screens/chats/chat/chat_controller.dart
T
sevenhill 317cfdae38
Tests / test (push) Canceled after 0s
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
Native crypto / native-crypto (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
Release IKAR 1.0.7: port navigation and sharing improvements, harden calls
2026-10-08 20:38:54 +03:00

457 lines
13 KiB
Dart

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<CachedMessage> messages = [];
final ValueNotifier<int> messagesRev = ValueNotifier(0);
bool hasMoreHistory = true;
bool isLoadingMore = false;
bool historyKickedOff = false;
bool loadingGap = false;
final List<HistoryGap> 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<void> 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<CachedMessage> olderDesc) {
if (olderDesc.isEmpty) return 0;
final existing = messages.map((m) => m.id).toSet();
final toAdd = <CachedMessage>[];
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<CachedMessage> decodedDesc) {
final byId = <String, CachedMessage>{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<List<CachedMessage>> 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<List<CachedMessage>> 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<List<CachedMessage>> 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<List<CachedMessage>> 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<bool> 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<CachedMessage> 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<int> 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<void> 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<void> loadRemainingHistory({
required void Function(List<CachedMessage> 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();
}
}