From 570a4396ebfc021b1b4e546173ee46144387f5bc Mon Sep 17 00:00:00 2001 From: Jganenok Date: Wed, 3 Jun 2026 13:55:14 +0700 Subject: [PATCH] =?UTF-8?q?=D0=BE=D0=BF=D1=82=D0=B8=D0=BC=D0=B8=D0=B7?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F:=20=D1=81=D0=BB=D0=B5=D0=B3=D0=BE?= =?UTF-8?q?=D0=BD=D1=8C=D1=86=D0=B0=20=D0=BD=D0=B0=D0=B1=D1=83=D1=80=D0=BC?= =?UTF-8?q?=D0=B0=D0=BB=D0=B4=D0=B8=D0=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/chats.dart | 12 +- lib/backend/modules/folders.dart | 9 +- lib/core/protocol/packet.dart | 36 +++-- lib/core/storage/app_database.dart | 21 +-- lib/core/transport/receiver.dart | 49 +++++-- lib/core/utils/media_cache.dart | 39 ++++- .../screens/chats/chat_list_screen.dart | 8 +- lib/frontend/screens/chats/chat_screen.dart | 137 +++++++++++------- lib/frontend/widgets/message_bubble.dart | 41 +++--- 9 files changed, 229 insertions(+), 123 deletions(-) diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index a45a8e9..bcc1a7a 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -512,12 +512,15 @@ class ChatsModule { if (accountId == null) return; final dialogRows = await AppDatabase.loadDialogChats(accountId); - final byParticipant = >>{}; + final byParticipant = + row, CachedChat cached})>>{}; for (final row in dialogRows) { final cached = CachedChat.fromDbRow(row); for (final pid in cached.participants.keys) { if (pid == accountId) continue; - byParticipant.putIfAbsent(pid, () => []).add(row); + byParticipant + .putIfAbsent(pid, () => []) + .add((row: row, cached: cached)); } } @@ -529,8 +532,9 @@ class ChatsModule { final options = ContactCache.getOptions(contactId) ?? const {}; final affected = byParticipant[contactId]; if (affected == null) continue; - for (final row in affected) { - final cached = CachedChat.fromDbRow(row); + for (final entry in affected) { + final row = entry.row; + final cached = entry.cached; final sameTitle = cached.title == name; final sameAvatar = (cached.iconUrl ?? '') == (avatar ?? ''); final sameOptions = cached.options.length == options.length && diff --git a/lib/backend/modules/folders.dart b/lib/backend/modules/folders.dart index b2856d8..afa4860 100644 --- a/lib/backend/modules/folders.dart +++ b/lib/backend/modules/folders.dart @@ -44,10 +44,13 @@ class FoldersModule { List? foldersOrder, ) { if (foldersOrder == null || foldersOrder.isEmpty) return; - final orderedIds = foldersOrder.map((id) => id.toString()).toList(); + final orderIndex = {}; + for (var i = 0; i < foldersOrder.length; i++) { + orderIndex.putIfAbsent(foldersOrder[i].toString(), () => i); + } folders.sort((a, b) { - final aIndex = orderedIds.indexOf(a.id); - final bIndex = orderedIds.indexOf(b.id); + final aIndex = orderIndex[a.id] ?? -1; + final bIndex = orderIndex[b.id] ?? -1; if (aIndex == -1 && bIndex == -1) return 0; if (aIndex == -1) return 1; if (bIndex == -1) return -1; diff --git a/lib/core/protocol/packet.dart b/lib/core/protocol/packet.dart index 5b2b16e..448a866 100644 --- a/lib/core/protocol/packet.dart +++ b/lib/core/protocol/packet.dart @@ -92,25 +92,31 @@ const int _compressionThreshold = 32; /// `0` — без сжатия, иначе `(rawLen ~/ compLen) + 1` (множитель размера, по /// которому получатель выделяет буфер под распаковку). Uint8List packPacket(int opcode, Map payload, {int seq = 0}) { - final header = ByteData(headerSize); + final Uint8List raw = msgpack.serialize(payload); + + final List body; + final int flag; + if (raw.length < _compressionThreshold) { + body = raw; + flag = 0; + } else { + body = lz4Compress(raw); + flag = (raw.length ~/ body.length) + 1; + } + + final out = Uint8List(headerSize + body.length); + final header = ByteData.view(out.buffer, out.offsetInBytes, headerSize); header.setUint8(0, 10); header.setUint8(1, CmdType.request); header.setUint16(2, seq, Endian.big); header.setUint16(4, opcode, Endian.big); - - final raw = Uint8List.fromList(msgpack.serialize(payload)); - - if (raw.length < _compressionThreshold) { - header.setUint32(6, raw.length & 0xFFFFFF, Endian.big); - return Uint8List.fromList(header.buffer.asUint8List() + raw); - } - - final compressed = lz4Compress(raw); - final compLen = compressed.length; - final flag = (raw.length ~/ compLen) + 1; - header.setUint32(6, ((flag & 0xFF) << 24) | (compLen & 0xFFFFFF), Endian.big); - - return Uint8List.fromList(header.buffer.asUint8List() + compressed); + header.setUint32( + 6, + ((flag & 0xFF) << 24) | (body.length & 0xFFFFFF), + Endian.big, + ); + out.setRange(headerSize, out.length, body); + return out; } /// Распаковка пакета от сервера diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index 2f62001..f1cccfa 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -435,17 +435,20 @@ class AppDatabase { // Chats cache static Future saveChats(List> rows) async { + if (rows.isEmpty) return; try { final db = await _instance; - final batch = db.batch(); - for (final row in rows) { - batch.insert( - 'chats_cache', - row, - conflictAlgorithm: ConflictAlgorithm.replace, - ); - } - await batch.commit(noResult: true); + await db.transaction((txn) async { + final batch = txn.batch(); + for (final row in rows) { + batch.insert( + 'chats_cache', + row, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + await batch.commit(noResult: true); + }); } catch (e) { logger.e("Ошибка при сохранении чата: $e"); } diff --git a/lib/core/transport/receiver.dart b/lib/core/transport/receiver.dart index e0a45ee..7321133 100644 --- a/lib/core/transport/receiver.dart +++ b/lib/core/transport/receiver.dart @@ -7,46 +7,73 @@ import '../utils/logger.dart'; /// Копит сырые байты из сокета, нарезает их на байтовые срезы целых пакетов. class PacketReceiver { Uint8List _buffer = Uint8List(0); + int _start = 0; + int _end = 0; static const int _maxBufferSize = 2 * 1024 * 1024; // 2 мегабуйта /// Добавляет байты в буфер и возвращает все собранные пакеты как сырые срезы. /// Полностью синхронный — нарезка не блокируется на распаковке, поэтому /// конкурентные вызовы из stream-листенера не могут пересечься на `_buffer`. + /// + /// Накопление идёт без перекопирования всего буфера на каждый чанк: целые + /// пакеты отдаются как `sublistView`, а потреблённый префикс отбрасывается + /// сдвигом указателя `_start`, а не пересборкой буфера. List feed(Uint8List data) { - final newBuffer = Uint8List(_buffer.length + data.length); - newBuffer.setAll(0, _buffer); - newBuffer.setAll(_buffer.length, data); - _buffer = newBuffer; + _append(data); - if (_buffer.length > _maxBufferSize) { + if (_end - _start > _maxBufferSize) { logger.e( - 'PacketReceiver: переполнение буфера (${_buffer.length} B), сброс', + 'PacketReceiver: переполнение буфера (${_end - _start} B), сброс', ); reset(); return const []; } final packets = []; - while (_buffer.length >= headerSize) { + while (_end - _start >= headerSize) { final bd = ByteData.view( _buffer.buffer, - _buffer.offsetInBytes, + _buffer.offsetInBytes + _start, headerSize, ); final packedLen = bd.getUint32(6, Endian.big); final payloadLength = packedLen & 0xFFFFFF; final totalLength = headerSize + payloadLength; - if (_buffer.length < totalLength) break; + if (_end - _start < totalLength) break; - packets.add(Uint8List.sublistView(_buffer, 0, totalLength)); - _buffer = _buffer.sublist(totalLength); + packets.add(Uint8List.sublistView(_buffer, _start, _start + totalLength)); + _start += totalLength; + } + + if (_start == _end) { + _start = 0; + _end = 0; } return packets; } + void _append(Uint8List data) { + final pending = _end - _start; + if (pending == 0) { + _buffer = Uint8List.fromList(data); + _start = 0; + _end = data.length; + return; + } + final total = pending + data.length; + final newBuffer = Uint8List(total); + newBuffer.setRange(0, pending, _buffer, _start); + newBuffer.setRange(pending, total, data); + _buffer = newBuffer; + _start = 0; + _end = total; + } + void reset() { _buffer = Uint8List(0); + _start = 0; + _end = 0; } } diff --git a/lib/core/utils/media_cache.dart b/lib/core/utils/media_cache.dart index b645561..cc274ac 100644 --- a/lib/core/utils/media_cache.dart +++ b/lib/core/utils/media_cache.dart @@ -15,6 +15,7 @@ class MediaCache { static int get maxBytes => AppMediaCacheLimit.current.value; static Directory? _dir; + static int? _cachedSize; static Future _cacheDir() async { final cached = _dir; @@ -81,6 +82,12 @@ class MediaCache { } await sink.close(); await part.rename(file.path); + final known = _cachedSize; + if (known != null) { + try { + _cachedSize = known + await file.length(); + } catch (_) {} + } await _enforceLimit(); return file; } catch (_) { @@ -96,7 +103,18 @@ class MediaCache { } /// Суммарный размер кэша в байтах. + /// + /// Результат держится в памяти и поддерживается инкрементально при + /// загрузке/очистке/вытеснении — повторные вызовы не пересканируют каталог. static Future currentSize() async { + final cached = _cachedSize; + if (cached != null) return cached; + final total = await _scanSize(); + _cachedSize = total; + return total; + } + + static Future _scanSize() async { final dir = await _cacheDir(); var total = 0; await for (final entity in dir.list()) { @@ -121,34 +139,43 @@ class MediaCache { } catch (_) {} } } + _cachedSize = 0; return freed; } /// Вытесняет старые файлы (по mtime), пока размер превышает [maxBytes]. + /// + /// Под лимитом — ранний выход без сканирования каталога (частый случай). + /// Каталог обходится только когда лимит реально превышен. static Future _enforceLimit() async { + final limit = maxBytes; + if (limit <= 0) return; + + var total = _cachedSize ?? await _scanSize(); + if (total <= limit) { + _cachedSize = total; + return; + } + final dir = await _cacheDir(); final files = []; - var total = 0; await for (final entity in dir.list()) { if (entity is File && !entity.path.endsWith('.part')) { files.add(entity); - try { - total += await entity.length(); - } catch (_) {} } } - if (maxBytes <= 0 || total <= maxBytes) return; files.sort((a, b) => a.statSync().modified.compareTo(b.statSync().modified)); for (final file in files) { - if (total <= maxBytes) break; + if (total <= limit) break; try { total -= await file.length(); await file.delete(); } catch (_) {} } + _cachedSize = total; } static String _sanitize(String name) { diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 4b66e47..25ba06a 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -183,8 +183,12 @@ class _ChatListScreenState extends State List _selectedChatObjects() { if (_selectedChats.isEmpty) return const []; - final ids = _selectedChats; - return _chats.where((c) => ids.contains(c.id.toString())).toList(); + final ids = {}; + for (final s in _selectedChats) { + final v = int.tryParse(s); + if (v != null) ids.add(v); + } + return _chats.where((c) => ids.contains(c.id)).toList(); } _DeleteKind _categorizeChat(CachedChat c, int myId) { diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 41e0cbe..35981c2 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -136,8 +136,10 @@ class _ChatScreenState extends State Timer? _shimmerStartTimer; bool _historyKickedOff = false; List _messages = []; + int _messagesRevision = 0; List? _combinedItemsCache; int? _combinedItemsKey; + bool _floatingDateScheduled = false; int _myId = 0; CachedChat? chat; @@ -146,7 +148,6 @@ class _ChatScreenState extends State late final AnimationController _floatingDateAnimController; late final CurvedAnimation _floatingDateCurved; final Map _separatorKeys = {}; - double _lastScrollOffset = 0; String? _lastSentId; @override @@ -226,6 +227,7 @@ class _ChatScreenState extends State .toList(); setState(() { _messages = first; + _messagesRevision++; _isLoading = false; _onLoadingFinished(); }); @@ -349,7 +351,10 @@ class _ChatScreenState extends State final changed = !_listsEquivalent(_messages, merged); if (!changed && !markLoaded) return; setState(() { - if (changed) _messages = merged; + if (changed) { + _messages = merged; + _messagesRevision++; + } if (markLoaded) { _isLoading = false; _onLoadingFinished(); @@ -590,6 +595,7 @@ class _ChatScreenState extends State setState(() { _lastSentId = message.id; _messages.add(message); + _messagesRevision++; }); _clearTyping(message.senderId); Haptics.tap(); @@ -598,11 +604,17 @@ class _ChatScreenState extends State case MessageEditedEvent(:final message): final idx = _messages.indexWhere((m) => m.id == message.id); if (idx == -1) return; - setState(() => _messages[idx] = message); + setState(() { + _messages[idx] = message; + _messagesRevision++; + }); case MessageRemovedEvent(:final messageId): final idx = _messages.indexWhere((m) => m.id == messageId); if (idx == -1) return; - setState(() => _messages.removeAt(idx)); + setState(() { + _messages.removeAt(idx); + _messagesRevision++; + }); _reactionNotifiers.remove(messageId)?.dispose(); case MessageReactionsChangedEvent(:final messageId, :final reactionInfo): _reactionNotifiers[messageId]?.value = reactionInfo; @@ -722,6 +734,7 @@ class _ChatScreenState extends State setState(() { _lastSentId = tempId; _messages.add(tempMessage); + _messagesRevision++; _messageController.clear(); }); unawaited(_persistOutgoing(tempMessage)); @@ -748,6 +761,7 @@ class _ChatScreenState extends State ); setState(() { _messages[index] = sent; + _messagesRevision++; }); unawaited(_persistOutgoing(sent, removeId: tempId)); } @@ -776,6 +790,7 @@ class _ChatScreenState extends State ); setState(() { _messages[index] = failed; + _messagesRevision++; }); unawaited(_persistOutgoing(failed)); } @@ -806,48 +821,61 @@ class _ChatScreenState extends State } if (forwardIds.isEmpty) return; + final resolved = {}; for (final id in forwardIds) { final name = await messagesModule.searchContactById(id); - final avatar = ContactCache.getAvatar(id); - if (name != null && mounted) { - setState(() { - for (var i = 0; i < _messages.length; i++) { - final msg = _messages[i]; - if (msg.attachments != null) { - final newAttaches = msg.attachments!.map((a) { - if (a is ForwardedMessageAttachment && - a.originalSenderId == id && - a.originalSenderName == null) { - return ForwardedMessageAttachment( - originalSenderId: id, - originalSenderName: name, - originalSenderAvatar: avatar, - originalMessageId: a.originalMessageId, - originalTime: a.originalTime, - originalText: a.originalText, - originalChatId: a.originalChatId, - originalAttachments: a.originalAttachments, - originalContact: a.originalContact, - ); - } - return a; - }).toList(); - _messages[i] = CachedMessage( - id: msg.id, - accountId: msg.accountId, - chatId: msg.chatId, - senderId: msg.senderId, - text: msg.text, - time: msg.time, - status: msg.status, - payload: msg.payload, - attachments: newAttaches, - ); - } - } - }); + if (name != null) { + resolved[id] = (name: name, avatar: ContactCache.getAvatar(id)); } } + if (resolved.isEmpty || !mounted) return; + + var anyChanged = false; + for (var i = 0; i < _messages.length; i++) { + final msg = _messages[i]; + final attaches = msg.attachments; + if (attaches == null) continue; + + var msgChanged = false; + final newAttaches = attaches.map((a) { + if (a is ForwardedMessageAttachment && + a.originalSenderName == null && + resolved.containsKey(a.originalSenderId)) { + final r = resolved[a.originalSenderId]!; + msgChanged = true; + return ForwardedMessageAttachment( + originalSenderId: a.originalSenderId, + originalSenderName: r.name, + originalSenderAvatar: r.avatar, + originalMessageId: a.originalMessageId, + originalTime: a.originalTime, + originalText: a.originalText, + originalChatId: a.originalChatId, + originalAttachments: a.originalAttachments, + originalContact: a.originalContact, + ); + } + return a; + }).toList(); + + if (!msgChanged) continue; + anyChanged = true; + _messages[i] = CachedMessage( + id: msg.id, + accountId: msg.accountId, + chatId: msg.chatId, + senderId: msg.senderId, + text: msg.text, + time: msg.time, + status: msg.status, + payload: msg.payload, + attachments: newAttaches, + ); + } + + if (anyChanged) { + setState(() => _messagesRevision++); + } } void _scrollToBottom() { @@ -863,7 +891,7 @@ class _ChatScreenState extends State } List _buildCombinedItems() { - final key = Object.hashAll(_messages.map(identityHashCode)); + final key = Object.hash(_messagesRevision, _messages.length); final cached = _combinedItemsCache; if (cached != null && _combinedItemsKey == key) return cached; @@ -906,25 +934,22 @@ class _ChatScreenState extends State void _onScrollForDate() { if (!_scrollController.hasClients) return; - final currentOffset = _scrollController.position.pixels; - final scrollingUp = currentOffset > _lastScrollOffset; - _lastScrollOffset = currentOffset; _floatingDateTimer?.cancel(); - - if (!scrollingUp) { - _floatingDateAnimController.reverse(); - return; - } - - _floatingDateTimer = Timer(const Duration(seconds: 2), () { + _floatingDateTimer = Timer(const Duration(seconds: 1), () { if (mounted) _floatingDateAnimController.reverse(); }); - WidgetsBinding.instance.addPostFrameCallback((_) => _updateFloatingDate()); + + if (_floatingDateScheduled) return; + _floatingDateScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _floatingDateScheduled = false; + _updateFloatingDate(); + }); } void _updateFloatingDate() { - if (!mounted) return; + if (!mounted || _separatorKeys.isEmpty) return; DateTime? result; final listRenderBox = _listKey.currentContext?.findRenderObject(); @@ -1611,6 +1636,7 @@ class _ChatScreenState extends State setState(() { _lastSentId = tempId; _messages.add(msg); + _messagesRevision++; }); Haptics.send(); _scrollToBottom(); @@ -1638,6 +1664,7 @@ class _ChatScreenState extends State payload: old.payload, attachments: attachment != null ? [attachment] : old.attachments, ); + _messagesRevision++; }); } diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index b5ffea5..a8f5ef9 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -1924,7 +1924,7 @@ class _VoiceMessageBubble extends StatefulWidget { class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> { bool _isPlaying = false; - double _progress = 0.0; + final ValueNotifier _progress = ValueNotifier(0.0); bool _transcriptionVisible = false; String? _transcriptionText; bool _transcriptionLoading = false; @@ -1935,6 +1935,12 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> { _transcriptionText = widget.preloadedText; } + @override + void dispose() { + _progress.dispose(); + super.dispose(); + } + String _formatDuration(int seconds) { final min = seconds ~/ 60; final sec = seconds % 60; @@ -2027,18 +2033,14 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> { builder: (context, constraints) { return GestureDetector( onTapDown: (details) { - setState(() { - _progress = (details.localPosition.dx / - constraints.maxWidth) - .clamp(0.0, 1.0); - }); + _progress.value = + (details.localPosition.dx / constraints.maxWidth) + .clamp(0.0, 1.0); }, onHorizontalDragUpdate: (details) { - setState(() { - _progress = (details.localPosition.dx / - constraints.maxWidth) - .clamp(0.0, 1.0); - }); + _progress.value = + (details.localPosition.dx / constraints.maxWidth) + .clamp(0.0, 1.0); }, child: Container( height: 4, @@ -2046,13 +2048,16 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> { color: waveInactiveColor, borderRadius: BorderRadius.circular(2), ), - child: FractionallySizedBox( - alignment: Alignment.centerLeft, - widthFactor: _progress.clamp(0.0, 1.0), - child: Container( - decoration: BoxDecoration( - color: waveActiveColor, - borderRadius: BorderRadius.circular(2), + child: ValueListenableBuilder( + valueListenable: _progress, + builder: (context, progress, _) => FractionallySizedBox( + alignment: Alignment.centerLeft, + widthFactor: progress.clamp(0.0, 1.0), + child: Container( + decoration: BoxDecoration( + color: waveActiveColor, + borderRadius: BorderRadius.circular(2), + ), ), ), ),