Merge branch 'feature/FullStack' of https://github.com/KometTeam/Komet into feature/FullStack
This commit is contained in:
+35
@@ -0,0 +1,35 @@
|
|||||||
|
import '../../backend/modules/messages.dart';
|
||||||
|
|
||||||
|
class CachedChatMessages {
|
||||||
|
final List<CachedMessage> messages;
|
||||||
|
final bool reachedStart;
|
||||||
|
|
||||||
|
const CachedChatMessages(this.messages, this.reachedStart);
|
||||||
|
}
|
||||||
|
|
||||||
|
class MessageSessionCache {
|
||||||
|
static final Map<String, CachedChatMessages> _store = {};
|
||||||
|
|
||||||
|
static String _key(int accountId, int chatId) => '$accountId:$chatId';
|
||||||
|
|
||||||
|
static CachedChatMessages? get(int accountId, int chatId) =>
|
||||||
|
_store[_key(accountId, chatId)];
|
||||||
|
|
||||||
|
static void save(
|
||||||
|
int accountId,
|
||||||
|
int chatId,
|
||||||
|
List<CachedMessage> messages, {
|
||||||
|
required bool reachedStart,
|
||||||
|
}) {
|
||||||
|
if (messages.isEmpty) return;
|
||||||
|
_store[_key(accountId, chatId)] = CachedChatMessages(
|
||||||
|
List<CachedMessage>.of(messages),
|
||||||
|
reachedStart,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void remove(int accountId, int chatId) =>
|
||||||
|
_store.remove(_key(accountId, chatId));
|
||||||
|
|
||||||
|
static void clearAll() => _store.clear();
|
||||||
|
}
|
||||||
@@ -712,6 +712,25 @@ class AppDatabase {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<List<Map<String, dynamic>>> loadMessagesBefore(
|
||||||
|
int accountId,
|
||||||
|
int chatId, {
|
||||||
|
required int beforeTime,
|
||||||
|
int limit = 30,
|
||||||
|
bool onlyVisible = false,
|
||||||
|
}) async {
|
||||||
|
final db = await _instance;
|
||||||
|
return db.query(
|
||||||
|
'messages',
|
||||||
|
where: onlyVisible
|
||||||
|
? 'account_id = ? AND chat_id = ? AND deleted = 0 AND time < ?'
|
||||||
|
: 'account_id = ? AND chat_id = ? AND time < ?',
|
||||||
|
whereArgs: [accountId, chatId, beforeTime],
|
||||||
|
orderBy: 'time DESC',
|
||||||
|
limit: limit,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
static Future<void> markMessageDeleted(
|
static Future<void> markMessageDeleted(
|
||||||
int accountId,
|
int accountId,
|
||||||
int chatId,
|
int chatId,
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import '../../../core/protocol/packet.dart';
|
|||||||
import '../../../core/storage/app_database.dart';
|
import '../../../core/storage/app_database.dart';
|
||||||
import '../../../core/storage/draft_store.dart';
|
import '../../../core/storage/draft_store.dart';
|
||||||
import '../../../core/cache/info_cache.dart';
|
import '../../../core/cache/info_cache.dart';
|
||||||
|
import '../../../core/cache/message_session_cache.dart';
|
||||||
import '../../../core/utils/haptics.dart';
|
import '../../../core/utils/haptics.dart';
|
||||||
import '../../../core/config/app_cache_extent.dart';
|
import '../../../core/config/app_cache_extent.dart';
|
||||||
import '../../../core/config/app_message_actions_style.dart';
|
import '../../../core/config/app_message_actions_style.dart';
|
||||||
@@ -181,6 +182,13 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
List<CachedMessage> _messages = [];
|
List<CachedMessage> _messages = [];
|
||||||
final ValueNotifier<int> _messagesRev = ValueNotifier(0);
|
final ValueNotifier<int> _messagesRev = ValueNotifier(0);
|
||||||
final Set<String> _deletingIds = {};
|
final Set<String> _deletingIds = {};
|
||||||
|
|
||||||
|
static const int _historyPageSize = 30;
|
||||||
|
static const int _historyInitialLimit = 50;
|
||||||
|
static const double _avgMessageHeight = 72.0;
|
||||||
|
static const double _historyPrefetchExtent = _avgMessageHeight * 8;
|
||||||
|
bool _isLoadingMore = false;
|
||||||
|
bool _hasMoreHistory = true;
|
||||||
List<Object>? _combinedItemsCache;
|
List<Object>? _combinedItemsCache;
|
||||||
int? _combinedItemsKey;
|
int? _combinedItemsKey;
|
||||||
bool _floatingDateScheduled = false;
|
bool _floatingDateScheduled = false;
|
||||||
@@ -208,6 +216,7 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
ChatsModule.chatsChanged.addListener(_onChatsBump);
|
ChatsModule.chatsChanged.addListener(_onChatsBump);
|
||||||
_messageController.addListener(_onTextChanged);
|
_messageController.addListener(_onTextChanged);
|
||||||
_scrollController.addListener(_onScrollForDate);
|
_scrollController.addListener(_onScrollForDate);
|
||||||
|
_scrollController.addListener(_maybeLoadMoreHistory);
|
||||||
AppVisualStyle.current.addListener(_onVisualStyleChanged);
|
AppVisualStyle.current.addListener(_onVisualStyleChanged);
|
||||||
_shimmerController = AnimationController(
|
_shimmerController = AnimationController(
|
||||||
vsync: this,
|
vsync: this,
|
||||||
@@ -292,6 +301,19 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
})
|
})
|
||||||
.catchError((_) {});
|
.catchError((_) {});
|
||||||
|
|
||||||
|
final cached = MessageSessionCache.get(_myId, widget.chatId);
|
||||||
|
if (cached != null && cached.messages.isNotEmpty) {
|
||||||
|
setState(() {
|
||||||
|
_messages = List<CachedMessage>.of(cached.messages);
|
||||||
|
_hasMoreHistory = !cached.reachedStart;
|
||||||
|
_messagesRev.value++;
|
||||||
|
_isLoading = false;
|
||||||
|
_onLoadingFinished();
|
||||||
|
});
|
||||||
|
_syncReactionNotifiersFromMessages();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final firstRows = await AppDatabase.loadMessages(
|
final firstRows = await AppDatabase.loadMessages(
|
||||||
_myId,
|
_myId,
|
||||||
widget.chatId,
|
widget.chatId,
|
||||||
@@ -417,11 +439,11 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
final fullRows = await AppDatabase.loadMessages(
|
final fullRows = await AppDatabase.loadMessages(
|
||||||
_myId,
|
_myId,
|
||||||
widget.chatId,
|
widget.chatId,
|
||||||
limit: 100,
|
limit: _historyInitialLimit,
|
||||||
onlyVisible: onlyVisible,
|
onlyVisible: onlyVisible,
|
||||||
);
|
);
|
||||||
final fullDecoded = await CachedMessage.fromDbRowsAsync(fullRows);
|
final fullDecoded = await CachedMessage.fromDbRowsAsync(fullRows);
|
||||||
if (mounted && fullDecoded.length > _messages.length) {
|
if (mounted) {
|
||||||
_applyMergedMessages(fullDecoded);
|
_applyMergedMessages(fullDecoded);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -459,7 +481,7 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
final updatedRows = await AppDatabase.loadMessages(
|
final updatedRows = await AppDatabase.loadMessages(
|
||||||
_myId,
|
_myId,
|
||||||
widget.chatId,
|
widget.chatId,
|
||||||
limit: 100,
|
limit: _historyInitialLimit,
|
||||||
onlyVisible: onlyVisible,
|
onlyVisible: onlyVisible,
|
||||||
);
|
);
|
||||||
final updatedDecoded = await CachedMessage.fromDbRowsAsync(updatedRows);
|
final updatedDecoded = await CachedMessage.fromDbRowsAsync(updatedRows);
|
||||||
@@ -481,19 +503,123 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _maybeLoadMoreHistory() {
|
||||||
|
if (!_scrollController.hasClients) return;
|
||||||
|
if (_isLoading || _isLoadingMore || !_hasMoreHistory) return;
|
||||||
|
if (_messages.isEmpty) return;
|
||||||
|
final pos = _scrollController.position;
|
||||||
|
if (pos.maxScrollExtent <= 0) return;
|
||||||
|
if (pos.maxScrollExtent - pos.pixels <= _historyPrefetchExtent) {
|
||||||
|
unawaited(_loadMoreHistory());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadMoreHistory() async {
|
||||||
|
if (_isLoadingMore || !_hasMoreHistory || _messages.isEmpty) return;
|
||||||
|
_isLoadingMore = true;
|
||||||
|
setState(() {});
|
||||||
|
|
||||||
|
final oldest = _messages.first;
|
||||||
|
final onlyVisible = !KometSettings.viewDeleted.value;
|
||||||
|
|
||||||
|
try {
|
||||||
|
var older = await _loadOlderFromDb(oldest.time, onlyVisible);
|
||||||
|
|
||||||
|
if (older.length < _historyPageSize) {
|
||||||
|
final fetched = await messagesModule.fetchHistory(
|
||||||
|
_myId,
|
||||||
|
widget.chatId,
|
||||||
|
fromTime: oldest.time,
|
||||||
|
count: _historyPageSize,
|
||||||
|
);
|
||||||
|
if (fetched.isNotEmpty) {
|
||||||
|
if (KometSettings.viewDeleted.value) {
|
||||||
|
await ChatsModule.reconcileDeletedFromFetch(
|
||||||
|
_myId,
|
||||||
|
widget.chatId,
|
||||||
|
fetched,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
older = await _loadOlderFromDb(oldest.time, onlyVisible);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
final added = _prependOlder(older);
|
||||||
|
setState(() {
|
||||||
|
_isLoadingMore = false;
|
||||||
|
if (added == 0) _hasMoreHistory = false;
|
||||||
|
});
|
||||||
|
_persistSessionCache();
|
||||||
|
} catch (e) {
|
||||||
|
logger.e('Error loading more history: $e');
|
||||||
|
if (mounted) setState(() => _isLoadingMore = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<CachedMessage>> _loadOlderFromDb(
|
||||||
|
int beforeTime,
|
||||||
|
bool onlyVisible,
|
||||||
|
) async {
|
||||||
|
final rows = await AppDatabase.loadMessagesBefore(
|
||||||
|
_myId,
|
||||||
|
widget.chatId,
|
||||||
|
beforeTime: beforeTime,
|
||||||
|
limit: _historyPageSize,
|
||||||
|
onlyVisible: onlyVisible,
|
||||||
|
);
|
||||||
|
return CachedMessage.fromDbRowsAsync(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
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++;
|
||||||
|
_syncReactionNotifiersFromMessages();
|
||||||
|
return toAdd.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _persistSessionCache() {
|
||||||
|
if (_myId == 0 || _messages.isEmpty) return;
|
||||||
|
MessageSessionCache.save(
|
||||||
|
_myId,
|
||||||
|
widget.chatId,
|
||||||
|
_messages,
|
||||||
|
reachedStart: !_hasMoreHistory,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void _applyMergedMessages(
|
void _applyMergedMessages(
|
||||||
List<CachedMessage> decodedDesc, {
|
List<CachedMessage> decodedDesc, {
|
||||||
bool markLoaded = false,
|
bool markLoaded = false,
|
||||||
}) {
|
}) {
|
||||||
final byId = <String, CachedMessage>{for (final m in _messages) m.id: m};
|
final byId = <String, CachedMessage>{for (final m in _messages) m.id: m};
|
||||||
final merged = <CachedMessage>[];
|
var changed = false;
|
||||||
for (final fresh in decodedDesc.reversed) {
|
for (final fresh in decodedDesc) {
|
||||||
final old = byId[fresh.id];
|
final old = byId[fresh.id];
|
||||||
merged.add(old != null && _sameMessage(old, fresh) ? old : fresh);
|
if (old == null) {
|
||||||
|
byId[fresh.id] = fresh;
|
||||||
|
changed = true;
|
||||||
|
} else if (!_sameMessage(old, fresh)) {
|
||||||
|
byId[fresh.id] = fresh;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final changed = !_listsEquivalent(_messages, merged);
|
|
||||||
if (!changed && !markLoaded) return;
|
if (!changed && !markLoaded) return;
|
||||||
|
|
||||||
|
final merged = byId.values.toList()
|
||||||
|
..sort((a, b) {
|
||||||
|
final byTime = a.time.compareTo(b.time);
|
||||||
|
return byTime != 0 ? byTime : a.id.compareTo(b.id);
|
||||||
|
});
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
if (changed) {
|
if (changed) {
|
||||||
_messages = merged;
|
_messages = merged;
|
||||||
@@ -507,6 +633,7 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
if (changed) {
|
if (changed) {
|
||||||
_syncReactionNotifiersFromMessages();
|
_syncReactionNotifiersFromMessages();
|
||||||
_pruneReactionNotifiers();
|
_pruneReactionNotifiers();
|
||||||
|
_persistSessionCache();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -542,14 +669,6 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
a.deleted == b.deleted;
|
a.deleted == b.deleted;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _listsEquivalent(List<CachedMessage> a, List<CachedMessage> b) {
|
|
||||||
if (a.length != b.length) return false;
|
|
||||||
for (var i = 0; i < a.length; i++) {
|
|
||||||
if (!identical(a[i], b[i])) return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void deactivate() {
|
void deactivate() {
|
||||||
_saveDraft();
|
_saveDraft();
|
||||||
@@ -567,6 +686,7 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_persistSessionCache();
|
||||||
if (_previewChat) {
|
if (_previewChat) {
|
||||||
unawaited(ChatsModule.subscribeChat(api, widget.chatId, subscribe: false));
|
unawaited(ChatsModule.subscribeChat(api, widget.chatId, subscribe: false));
|
||||||
}
|
}
|
||||||
@@ -576,6 +696,7 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
_saveDraft();
|
_saveDraft();
|
||||||
_messageController.removeListener(_onTextChanged);
|
_messageController.removeListener(_onTextChanged);
|
||||||
_scrollController.removeListener(_onScrollForDate);
|
_scrollController.removeListener(_onScrollForDate);
|
||||||
|
_scrollController.removeListener(_maybeLoadMoreHistory);
|
||||||
AppVisualStyle.current.removeListener(_onVisualStyleChanged);
|
AppVisualStyle.current.removeListener(_onVisualStyleChanged);
|
||||||
_floatingDateTimer?.cancel();
|
_floatingDateTimer?.cancel();
|
||||||
_floatingDateCurved.dispose();
|
_floatingDateCurved.dispose();
|
||||||
@@ -991,7 +1112,11 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _replySelected() {
|
void _replySelected() {
|
||||||
showCustomNotification(context, 'Ответ — пока в разработке');
|
final msgs = _selectedMessages(_selectedIds.value);
|
||||||
|
if (msgs.isEmpty) return;
|
||||||
|
final message = msgs.first;
|
||||||
|
_clearSelection();
|
||||||
|
_startReply(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _forwardSelected() {
|
void _forwardSelected() {
|
||||||
@@ -2916,6 +3041,23 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildLoadMoreIndicator() {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
child: Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2.2,
|
||||||
|
color: cs.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildMessagesListContent() {
|
Widget _buildMessagesListContent() {
|
||||||
if (_messages.isEmpty) {
|
if (_messages.isEmpty) {
|
||||||
return Center(
|
return Center(
|
||||||
@@ -2942,8 +3084,11 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
reverse: true,
|
reverse: true,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
cacheExtent: cacheExtent,
|
cacheExtent: cacheExtent,
|
||||||
itemCount: items.length,
|
itemCount: items.length + (_isLoadingMore ? 1 : 0),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
|
if (index >= items.length) {
|
||||||
|
return _buildLoadMoreIndicator();
|
||||||
|
}
|
||||||
final item = items[items.length - 1 - index];
|
final item = items[items.length - 1 - index];
|
||||||
|
|
||||||
if (item is _DateSeparatorItem) {
|
if (item is _DateSeparatorItem) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'dart:io';
|
|||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/rendering.dart';
|
||||||
import 'package:komet/main.dart';
|
import 'package:komet/main.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
import '../../backend/modules/messages.dart';
|
import '../../backend/modules/messages.dart';
|
||||||
@@ -54,6 +55,22 @@ class _BubbleCtx {
|
|||||||
final Expando<MessageType> _contentTypeCache = Expando<MessageType>();
|
final Expando<MessageType> _contentTypeCache = Expando<MessageType>();
|
||||||
final Expando<({bool full, String text})> _clockTextCache = Expando();
|
final Expando<({bool full, String text})> _clockTextCache = Expando();
|
||||||
|
|
||||||
|
class _ZeroIntrinsicWidth extends SingleChildRenderObjectWidget {
|
||||||
|
const _ZeroIntrinsicWidth({required Widget super.child});
|
||||||
|
|
||||||
|
@override
|
||||||
|
RenderObject createRenderObject(BuildContext context) =>
|
||||||
|
_RenderZeroIntrinsicWidth();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RenderZeroIntrinsicWidth extends RenderProxyBox {
|
||||||
|
@override
|
||||||
|
double computeMinIntrinsicWidth(double height) => 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
double computeMaxIntrinsicWidth(double height) => 0;
|
||||||
|
}
|
||||||
|
|
||||||
class MessageBubble extends StatelessWidget {
|
class MessageBubble extends StatelessWidget {
|
||||||
static const double photoMaxSize = 280.0;
|
static const double photoMaxSize = 280.0;
|
||||||
static const double photoMinSize = 100.0;
|
static const double photoMinSize = 100.0;
|
||||||
@@ -375,14 +392,24 @@ class MessageBubble extends StatelessWidget {
|
|||||||
final reply = message.replyInfo;
|
final reply = message.replyInfo;
|
||||||
Widget withReply(Widget content) {
|
Widget withReply(Widget content) {
|
||||||
if (reply == null) return content;
|
if (reply == null) return content;
|
||||||
return Column(
|
final quote = _buildReplyQuote(context, cs, textColor, reply);
|
||||||
mainAxisSize: MainAxisSize.min,
|
if (contentType != MessageType.text) {
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
return Column(
|
||||||
children: [
|
mainAxisSize: MainAxisSize.min,
|
||||||
_buildReplyQuote(context, cs, textColor, reply),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
const SizedBox(height: 4),
|
children: [quote, const SizedBox(height: 4), content],
|
||||||
content,
|
);
|
||||||
],
|
}
|
||||||
|
return IntrinsicWidth(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
_ZeroIntrinsicWidth(child: quote),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
content,
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user