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
457 lines
13 KiB
Dart
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();
|
|
}
|
|
}
|