diff --git a/lib/core/cache/message_session_cache.dart b/lib/core/cache/message_session_cache.dart new file mode 100644 index 0000000..e9d043c --- /dev/null +++ b/lib/core/cache/message_session_cache.dart @@ -0,0 +1,35 @@ +import '../../backend/modules/messages.dart'; + +class CachedChatMessages { + final List messages; + final bool reachedStart; + + const CachedChatMessages(this.messages, this.reachedStart); +} + +class MessageSessionCache { + static final Map _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 messages, { + required bool reachedStart, + }) { + if (messages.isEmpty) return; + _store[_key(accountId, chatId)] = CachedChatMessages( + List.of(messages), + reachedStart, + ); + } + + static void remove(int accountId, int chatId) => + _store.remove(_key(accountId, chatId)); + + static void clearAll() => _store.clear(); +} diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index 5d3efa7..ba1ba67 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -712,6 +712,25 @@ class AppDatabase { ); } + static Future>> 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 markMessageDeleted( int accountId, int chatId, diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 5bbf361..b05d34b 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -31,6 +31,7 @@ import '../../../core/protocol/packet.dart'; import '../../../core/storage/app_database.dart'; import '../../../core/storage/draft_store.dart'; import '../../../core/cache/info_cache.dart'; +import '../../../core/cache/message_session_cache.dart'; import '../../../core/utils/haptics.dart'; import '../../../core/config/app_cache_extent.dart'; import '../../../core/config/app_message_actions_style.dart'; @@ -181,6 +182,13 @@ class _ChatScreenState extends State List _messages = []; final ValueNotifier _messagesRev = ValueNotifier(0); final Set _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? _combinedItemsCache; int? _combinedItemsKey; bool _floatingDateScheduled = false; @@ -208,6 +216,7 @@ class _ChatScreenState extends State ChatsModule.chatsChanged.addListener(_onChatsBump); _messageController.addListener(_onTextChanged); _scrollController.addListener(_onScrollForDate); + _scrollController.addListener(_maybeLoadMoreHistory); AppVisualStyle.current.addListener(_onVisualStyleChanged); _shimmerController = AnimationController( vsync: this, @@ -292,6 +301,19 @@ class _ChatScreenState extends State }) .catchError((_) {}); + final cached = MessageSessionCache.get(_myId, widget.chatId); + if (cached != null && cached.messages.isNotEmpty) { + setState(() { + _messages = List.of(cached.messages); + _hasMoreHistory = !cached.reachedStart; + _messagesRev.value++; + _isLoading = false; + _onLoadingFinished(); + }); + _syncReactionNotifiersFromMessages(); + return; + } + final firstRows = await AppDatabase.loadMessages( _myId, widget.chatId, @@ -417,11 +439,11 @@ class _ChatScreenState extends State final fullRows = await AppDatabase.loadMessages( _myId, widget.chatId, - limit: 100, + limit: _historyInitialLimit, onlyVisible: onlyVisible, ); final fullDecoded = await CachedMessage.fromDbRowsAsync(fullRows); - if (mounted && fullDecoded.length > _messages.length) { + if (mounted) { _applyMergedMessages(fullDecoded); } @@ -459,7 +481,7 @@ class _ChatScreenState extends State final updatedRows = await AppDatabase.loadMessages( _myId, widget.chatId, - limit: 100, + limit: _historyInitialLimit, onlyVisible: onlyVisible, ); final updatedDecoded = await CachedMessage.fromDbRowsAsync(updatedRows); @@ -481,19 +503,123 @@ class _ChatScreenState extends State } } + 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 _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> _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 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++; + _syncReactionNotifiersFromMessages(); + return toAdd.length; + } + + void _persistSessionCache() { + if (_myId == 0 || _messages.isEmpty) return; + MessageSessionCache.save( + _myId, + widget.chatId, + _messages, + reachedStart: !_hasMoreHistory, + ); + } + void _applyMergedMessages( List decodedDesc, { bool markLoaded = false, }) { final byId = {for (final m in _messages) m.id: m}; - final merged = []; - for (final fresh in decodedDesc.reversed) { + var changed = false; + for (final fresh in decodedDesc) { 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; + + 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(() { if (changed) { _messages = merged; @@ -507,6 +633,7 @@ class _ChatScreenState extends State if (changed) { _syncReactionNotifiersFromMessages(); _pruneReactionNotifiers(); + _persistSessionCache(); } } @@ -542,14 +669,6 @@ class _ChatScreenState extends State a.deleted == b.deleted; } - bool _listsEquivalent(List a, List 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 void deactivate() { _saveDraft(); @@ -567,6 +686,7 @@ class _ChatScreenState extends State @override void dispose() { + _persistSessionCache(); if (_previewChat) { unawaited(ChatsModule.subscribeChat(api, widget.chatId, subscribe: false)); } @@ -576,6 +696,7 @@ class _ChatScreenState extends State _saveDraft(); _messageController.removeListener(_onTextChanged); _scrollController.removeListener(_onScrollForDate); + _scrollController.removeListener(_maybeLoadMoreHistory); AppVisualStyle.current.removeListener(_onVisualStyleChanged); _floatingDateTimer?.cancel(); _floatingDateCurved.dispose(); @@ -991,7 +1112,11 @@ class _ChatScreenState extends State } void _replySelected() { - showCustomNotification(context, 'Ответ — пока в разработке'); + final msgs = _selectedMessages(_selectedIds.value); + if (msgs.isEmpty) return; + final message = msgs.first; + _clearSelection(); + _startReply(message); } void _forwardSelected() { @@ -2916,6 +3041,23 @@ class _ChatScreenState extends State ); } + 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() { if (_messages.isEmpty) { return Center( @@ -2942,8 +3084,11 @@ class _ChatScreenState extends State reverse: true, padding: const EdgeInsets.symmetric(vertical: 8), cacheExtent: cacheExtent, - itemCount: items.length, + itemCount: items.length + (_isLoadingMore ? 1 : 0), itemBuilder: (context, index) { + if (index >= items.length) { + return _buildLoadMoreIndicator(); + } final item = items[items.length - 1 - index]; if (item is _DateSeparatorItem) { diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index de4947b..64db1b1 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:komet/main.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../backend/modules/messages.dart'; @@ -54,6 +55,22 @@ class _BubbleCtx { final Expando _contentTypeCache = 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 { static const double photoMaxSize = 280.0; static const double photoMinSize = 100.0; @@ -375,14 +392,24 @@ class MessageBubble extends StatelessWidget { final reply = message.replyInfo; Widget withReply(Widget content) { if (reply == null) return content; - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildReplyQuote(context, cs, textColor, reply), - const SizedBox(height: 4), - content, - ], + final quote = _buildReplyQuote(context, cs, textColor, reply); + if (contentType != MessageType.text) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [quote, const SizedBox(height: 4), content], + ); + } + return IntrinsicWidth( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _ZeroIntrinsicWidth(child: quote), + const SizedBox(height: 4), + content, + ], + ), ); }