diff --git a/lib/backend/modules/chat_parsing.dart b/lib/backend/modules/chat_parsing.dart index 5c94c17..9cda63f 100644 --- a/lib/backend/modules/chat_parsing.dart +++ b/lib/backend/modules/chat_parsing.dart @@ -58,6 +58,7 @@ CachedChat? parseChatRow( lastMsgTime: lastMessage.time, lastMsgText: lastMessage.text, lastMsgElements: lastMessage.elements, + lastMsgPreview: lastMessage.preview, lastMsgSenderId: lastMessage.senderId, unreadCount: (chat['newMessages'] as int?) ?? 0, lastEventTime: (chat['lastEventTime'] as int?) ?? 0, @@ -125,16 +126,31 @@ CachedChat? parseChatRow( ); } -({int? id, int? time, String? text, String? elements, int? senderId}) +({ + int? id, + int? time, + String? text, + String? elements, + String? preview, + int? senderId, +}) _resolveLastMessage(dynamic lastMsg) { if (lastMsg is! Map) { - return (id: null, time: null, text: null, elements: null, senderId: null); + return ( + id: null, + time: null, + text: null, + elements: null, + preview: null, + senderId: null, + ); } return ( id: lastMsg['id'] as int?, time: lastMsg['time'] as int?, text: messagePreviewText(lastMsg), elements: messagePreviewElements(lastMsg), + preview: messagePreviewMedia(lastMsg), senderId: lastMsg['sender'] as int?, ); } @@ -308,6 +324,7 @@ bool sameChatContent(CachedChat a, CachedChat b) { if (a.lastMsgTime != b.lastMsgTime) return false; if (a.lastMsgText != b.lastMsgText) return false; if (a.lastMsgElements != b.lastMsgElements) return false; + if (a.lastMsgPreview != b.lastMsgPreview) return false; if (a.lastMsgSenderId != b.lastMsgSenderId) return false; if (a.unreadCount != b.unreadCount) return false; if (a.lastEventTime != b.lastEventTime) return false; diff --git a/lib/backend/modules/chat_preview.dart b/lib/backend/modules/chat_preview.dart index a3e3d2b..2888cfd 100644 --- a/lib/backend/modules/chat_preview.dart +++ b/lib/backend/modules/chat_preview.dart @@ -1,56 +1,121 @@ import 'dart:convert'; +import '../../models/attachment.dart'; +import '../../models/chat_preview_media.dart'; + +const int _maxPreviewThumbs = 3; +const int _maxThumbLength = 20000; + String? attachPreviewLabel(dynamic attaches) { + final parts = _attachPreviewParts(attaches); + if (parts == null) return null; + final detail = parts.detail; + return detail == null ? parts.label : '${parts.label}: $detail'; +} + +({String label, String? detail})? _attachPreviewParts(dynamic attaches) { final first = _firstPreviewAttach(attaches); if (first == null) return null; final type = (first['_type'] as String? ?? '').toUpperCase(); switch (type) { case 'PHOTO': - return 'Фото'; + return ( + label: _mediaAttachCount(attaches) > 1 ? 'Изображения' : 'Изображение', + detail: null, + ); case 'VIDEO': - return _isVideoNote(first) ? 'Видео-сообщение' : 'Видео'; + if (_isVideoNote(first)) return (label: 'Видео-сообщение', detail: null); + return (label: 'Видео', detail: null); case 'AUDIO': - return 'Голосовое сообщение'; + return (label: 'Голосовое сообщение', detail: null); case 'FILE': - final name = first['name']?.toString(); - return name != null && name.isNotEmpty ? 'Файл: $name' : 'Файл'; + return (label: 'Файл', detail: _nonEmpty(first['name'])); case 'STICKER': - return 'Стикер'; + return (label: 'Стикер', detail: null); case 'SHARE': - final title = first['title']?.toString(); - return title != null && title.isNotEmpty ? 'Ссылка: $title' : 'Ссылка'; + return (label: 'Ссылка', detail: _nonEmpty(first['title'])); case 'POLL': - final title = first['title']?.toString(); - return title != null && title.isNotEmpty ? 'Опрос: $title' : 'Опрос'; + return (label: 'Опрос', detail: _nonEmpty(first['title'])); case 'LOCATION': - return 'Геопозиция'; + return (label: 'Геопозиция', detail: null); case 'CONTACT': - return 'Контакт'; + return (label: 'Контакт', detail: null); case 'CONTROL': - return _controlPreviewLabel(first); + final label = _controlPreviewLabel(first); + return label == null ? null : (label: label, detail: null); case 'INLINE_KEYBOARD': return null; case 'CALL': - final video = first['callType']?.toString().toUpperCase() == 'VIDEO'; - final dur = (first['duration'] as num?)?.toInt() ?? 0; - final hangup = first['hangupType']?.toString(); - final failed = - dur == 0 || - hangup == 'CANCELED' || - hangup == 'REJECTED' || - hangup == 'MISSED'; - if (first['joinLink'] != null) { - return video ? 'Групповой видеозвонок' : 'Групповой звонок'; - } - if (failed) { - return video ? 'Пропущенный видеозвонок' : 'Пропущенный звонок'; - } - return video ? 'Видеозвонок' : 'Звонок'; + return (label: _callPreviewLabel(first), detail: null); default: - return 'Вложение'; + return (label: 'Вложение', detail: null); } } +ChatPreviewKind? _attachPreviewKind(Map attach) { + switch ((attach['_type'] as String? ?? '').toUpperCase()) { + case 'PHOTO': + return ChatPreviewKind.photo; + case 'VIDEO': + return _isVideoNote(attach) + ? ChatPreviewKind.videoNote + : ChatPreviewKind.video; + case 'AUDIO': + return ChatPreviewKind.audio; + case 'FILE': + return ChatPreviewKind.file; + case 'STICKER': + return ChatPreviewKind.sticker; + case 'SHARE': + return ChatPreviewKind.share; + case 'POLL': + return ChatPreviewKind.poll; + case 'LOCATION': + return ChatPreviewKind.location; + case 'CONTACT': + return ChatPreviewKind.contact; + case 'CONTROL': + return ChatPreviewKind.control; + case 'INLINE_KEYBOARD': + return null; + case 'CALL': + final video = attach['callType']?.toString().toUpperCase() == 'VIDEO'; + if (_isFailedCall(attach)) { + return video + ? ChatPreviewKind.missedVideoCall + : ChatPreviewKind.missedCall; + } + return video ? ChatPreviewKind.videoCall : ChatPreviewKind.call; + default: + return ChatPreviewKind.other; + } +} + +String _callPreviewLabel(Map attach) { + final video = attach['callType']?.toString().toUpperCase() == 'VIDEO'; + if (attach['joinLink'] != null) { + return video ? 'Групповой видеозвонок' : 'Групповой звонок'; + } + if (_isFailedCall(attach)) { + return video ? 'Пропущенный видеозвонок' : 'Пропущенный звонок'; + } + return video ? 'Видеозвонок' : 'Звонок'; +} + +bool _isFailedCall(Map attach) { + final duration = (attach['duration'] as num?)?.toInt() ?? 0; + final hangup = attach['hangupType']?.toString(); + return duration == 0 || + hangup == 'CANCELED' || + hangup == 'REJECTED' || + hangup == 'MISSED'; +} + +String? _nonEmpty(dynamic raw) { + final value = raw?.toString(); + return value != null && value.isNotEmpty ? value : null; +} + Map? _firstPreviewAttach(dynamic attaches) { if (attaches is! List || attaches.isEmpty) return null; for (final attach in attaches) { @@ -62,6 +127,16 @@ Map? _firstPreviewAttach(dynamic attaches) { return null; } +int _mediaAttachCount(dynamic attaches) { + if (attaches is! List) return 0; + var count = 0; + for (final attach in attaches.whereType()) { + final type = (attach['_type'] as String? ?? '').toUpperCase(); + if (type == 'PHOTO' || (type == 'VIDEO' && !_isVideoNote(attach))) count++; + } + return count; +} + bool _isVideoNote(Map attach) { final raw = attach['videoType']; if (raw is int) return raw == 1; @@ -95,9 +170,8 @@ String? _controlPreviewLabel(Map c) { } String? messagePreviewText(Map msg) { - final link = msg['link']; - if (link is Map && link['type']?.toString().toUpperCase() == 'FORWARD') { - final original = link['message']; + final original = _forwardOrigin(msg); + if (original != null) { final inner = original is Map ? _bodyPreviewText(original) : null; return inner != null && inner.isNotEmpty ? '↪ $inner' @@ -106,6 +180,62 @@ String? messagePreviewText(Map msg) { return _bodyPreviewText(msg); } +String? messagePreviewMedia(Map msg) { + final origin = _forwardOrigin(msg); + final body = origin ?? msg; + if (body is! Map) return null; + final first = _firstPreviewAttach(body['attaches']); + if (first == null) return null; + final kind = _attachPreviewKind(first); + if (kind == null) return null; + + final text = body['text']?.toString(); + final captioned = text != null && text.isNotEmpty; + final parts = captioned ? null : _attachPreviewParts(body['attaches']); + final label = parts == null + ? null + : (origin == null ? parts.label : '↪ ${parts.label}'); + + return ChatPreviewMedia( + kind: kind, + thumbs: _previewThumbs(body['attaches']), + label: label, + detail: parts?.detail, + ).encode(); +} + +dynamic _forwardOrigin(Map msg) { + final link = msg['link']; + if (link is! Map) return null; + if (link['type']?.toString().toUpperCase() != 'FORWARD') return null; + return link['message']; +} + +List _previewThumbs(dynamic attaches) { + if (attaches is! List) return const []; + final thumbs = []; + for (final attach in attaches.whereType()) { + if (thumbs.length >= _maxPreviewThumbs) break; + final type = (attach['_type'] as String? ?? '').toUpperCase(); + final isVideo = type == 'VIDEO'; + if (type != 'PHOTO' && !isVideo) continue; + final source = _thumbSource(attach, isVideo); + if (source == null) continue; + thumbs.add(ChatPreviewThumb(source: source, video: isVideo)); + } + return thumbs; +} + +String? _thumbSource(Map attach, bool isVideo) { + final data = decodeAttachPreview(attach['previewData']); + if (data != null && data.length <= _maxThumbLength) return data; + final url = isVideo + ? _nonEmpty(attach['thumbnail']) + : _nonEmpty(attach['baseUrl']); + if (url != null && url.startsWith('http')) return url; + return null; +} + ({String? text, bool isPreview}) pinnedMessagePreview(Map msg) { final link = msg['link']; if (link is Map && link['type']?.toString().toUpperCase() == 'FORWARD') { diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index bda1158..e981719 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -14,6 +14,7 @@ import '../../core/storage/chat_members_store.dart'; import '../../core/storage/token_storage.dart'; import '../../core/utils/logger.dart'; import '../../core/utils/text_format.dart'; +import '../../models/chat_preview_media.dart'; import '../../models/contact_info.dart'; import '../api.dart'; import 'chat_parsing.dart'; @@ -63,6 +64,7 @@ class CachedChat { final String? lastMsgText; final String? lastMsgTextOneLine; final String? lastMsgElements; + final String? lastMsgPreview; final int? lastMsgSenderId; final String? lastMsgStatus; final int unreadCount; @@ -92,6 +94,7 @@ class CachedChat { this.lastMsgTime, this.lastMsgText, this.lastMsgElements, + this.lastMsgPreview, this.lastMsgSenderId, this.lastMsgStatus, required this.unreadCount, @@ -116,6 +119,10 @@ class CachedChat { bool get isOfficial => options.contains('OFFICIAL'); + late final ChatPreviewMedia? lastMsgMedia = ChatPreviewMedia.decode( + lastMsgPreview, + ); + List get lastMsgFormatRanges { final raw = lastMsgElements; if (raw == null || raw.isEmpty) return const []; @@ -176,6 +183,7 @@ class CachedChat { lastMsgTime: row['last_msg_time'] as int?, lastMsgText: row['last_msg_text'] as String?, lastMsgElements: row['last_msg_elements'] as String?, + lastMsgPreview: row['last_msg_preview'] as String?, lastMsgSenderId: row['last_msg_sender'] as int?, lastMsgStatus: row['last_msg_status'] as String?, unreadCount: row['unread_count'] as int, @@ -220,6 +228,7 @@ class CachedChat { 'last_msg_time': lastMsgTime, 'last_msg_text': lastMsgText, 'last_msg_elements': lastMsgElements, + 'last_msg_preview': lastMsgPreview, 'last_msg_sender': lastMsgSenderId, 'last_msg_status': lastMsgStatus, 'unread_count': unreadCount, @@ -252,6 +261,7 @@ class CachedChat { Object? lastMsgTime = _keep, Object? lastMsgText = _keep, Object? lastMsgElements = _keep, + Object? lastMsgPreview = _keep, Object? lastMsgSenderId = _keep, Object? lastMsgStatus = _keep, int? unreadCount, @@ -289,6 +299,9 @@ class CachedChat { lastMsgElements: identical(lastMsgElements, _keep) ? this.lastMsgElements : lastMsgElements as String?, + lastMsgPreview: identical(lastMsgPreview, _keep) + ? this.lastMsgPreview + : lastMsgPreview as String?, lastMsgSenderId: identical(lastMsgSenderId, _keep) ? this.lastMsgSenderId : lastMsgSenderId as int?, @@ -547,6 +560,7 @@ class ChatsModule { required String text, required String status, List>? elements, + String? preview, }) async { final thisId = int.tryParse(messageId); await _updateChat(accountId, chatId, (chat) { @@ -558,6 +572,7 @@ class ChatsModule { lastMsgElements: (elements != null && elements.isNotEmpty) ? jsonEncode(elements) : null, + lastMsgPreview: preview, lastMsgTime: time, lastEventTime: time, lastMsgSenderId: accountId, @@ -891,6 +906,7 @@ class ChatsModule { } newRow['last_msg_text'] = messagePreviewText(msg); newRow['last_msg_elements'] = messagePreviewElements(msg); + newRow['last_msg_preview'] = messagePreviewMedia(msg); if (senderId != null) newRow['last_msg_sender'] = senderId; newRow['last_msg_status'] = 'sent'; } @@ -960,7 +976,9 @@ class ChatsModule { final rawText = m['text']?.toString(); String? previewText = rawText; String? elementsJson; + String? previewMedia; final payload = _decodePayload(m['payload']); + if (payload != null) previewMedia = messagePreviewMedia(payload); if (rawText == null || rawText.isEmpty) { if (payload != null) previewText = messagePreviewText(payload); } else { @@ -969,6 +987,7 @@ class ChatsModule { newRow['last_msg_id'] = int.tryParse(m['id']?.toString() ?? ''); newRow['last_msg_text'] = previewText ?? m['text']; newRow['last_msg_elements'] = elementsJson; + newRow['last_msg_preview'] = previewMedia; newRow['last_msg_time'] = m['time']; newRow['last_msg_sender'] = m['sender_id']; newRow['last_msg_status'] = m['status']; @@ -976,6 +995,7 @@ class ChatsModule { newRow['last_msg_id'] = null; newRow['last_msg_text'] = lastMsgPlaceholder; newRow['last_msg_elements'] = null; + newRow['last_msg_preview'] = null; newRow['last_msg_sender'] = null; newRow['last_msg_status'] = null; } diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index 574d7ab..7101456 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -221,7 +221,7 @@ class AppDatabase { await _migrateLegacyDb(target); return openDatabase( target, - version: 20, + version: 21, onOpen: (db) => db.execute('PRAGMA foreign_keys = ON'), onCreate: (db, _) => _createTables(db), onUpgrade: (db, oldVersion, newVersion) async { @@ -341,6 +341,14 @@ class AppDatabase { 'INTEGER', ); } + if (oldVersion < 21) { + await _addColumnIfMissing( + db, + 'chats_cache', + 'last_msg_preview', + 'TEXT', + ); + } }, ); } @@ -475,6 +483,7 @@ class AppDatabase { last_msg_time INTEGER, last_msg_text TEXT, last_msg_elements TEXT, + last_msg_preview TEXT, last_msg_sender INTEGER, last_msg_status TEXT, unread_count INTEGER NOT NULL DEFAULT 0, diff --git a/lib/frontend/screens/chats/chat/chat_controller.dart b/lib/frontend/screens/chats/chat/chat_controller.dart index f920e34..81270e1 100644 --- a/lib/frontend/screens/chats/chat/chat_controller.dart +++ b/lib/frontend/screens/chats/chat/chat_controller.dart @@ -45,6 +45,11 @@ class ChatController extends ChangeNotifier { bool get hasGap => gaps.isNotEmpty; + static bool gapFillLeavesViewportInPlace( + HistoryGap gap, + int? oldestRenderedTime, + ) => oldestRenderedTime != null && oldestRenderedTime >= gap.tailTime; + bool Function() isMounted = () => true; void bump() { @@ -219,7 +224,10 @@ class ChatController extends ChangeNotifier { if (gaps.isEmpty) persistSessionCache(); } - Future fillGapForward(HistoryGap gap) async { + Future 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); @@ -255,7 +263,10 @@ class ChatController extends ChangeNotifier { ); if (!isMounted()) return 0; if (refreshed.length <= slice.length) { - if (refreshed.isNotEmpty) mergeMessages(refreshed); + if (refreshed.isNotEmpty) { + beforeApply?.call(); + mergeMessages(refreshed); + } _closeGap(gap); return refreshed.length; } @@ -267,6 +278,7 @@ class ChatController extends ChangeNotifier { return 0; } + beforeApply?.call(); mergeMessages(slice); var edge = slice.first; diff --git a/lib/frontend/screens/chats/chat/view/chat_preview_line.dart b/lib/frontend/screens/chats/chat/view/chat_preview_line.dart new file mode 100644 index 0000000..a9c55fb --- /dev/null +++ b/lib/frontend/screens/chats/chat/view/chat_preview_line.dart @@ -0,0 +1,247 @@ +import 'dart:convert'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import 'package:komet/core/utils/text_format.dart'; +import 'package:komet/frontend/widgets/formatted_message_text.dart'; +import 'package:komet/models/chat_preview_media.dart'; + +const String _forwardMark = '↪ '; + +IconData? chatKindIcon(String chatType, {required bool isBot}) { + switch (chatType) { + case 'CHANNEL': + return Symbols.campaign; + case 'CHAT': + case 'GROUP': + return Symbols.group; + default: + return isBot ? Symbols.smart_toy : null; + } +} + +IconData previewKindIcon(ChatPreviewKind kind) { + switch (kind) { + case ChatPreviewKind.photo: + return Symbols.image; + case ChatPreviewKind.video: + return Symbols.movie; + case ChatPreviewKind.videoNote: + return Symbols.videocam; + case ChatPreviewKind.audio: + return Symbols.mic; + case ChatPreviewKind.file: + return Symbols.description; + case ChatPreviewKind.sticker: + return Symbols.emoji_emotions; + case ChatPreviewKind.contact: + return Symbols.person; + case ChatPreviewKind.location: + return Symbols.location_on; + case ChatPreviewKind.poll: + return Symbols.bar_chart; + case ChatPreviewKind.share: + return Symbols.link; + case ChatPreviewKind.call: + return Symbols.call; + case ChatPreviewKind.missedCall: + return Symbols.call_missed; + case ChatPreviewKind.videoCall: + return Symbols.videocam; + case ChatPreviewKind.missedVideoCall: + return Symbols.missed_video_call; + case ChatPreviewKind.control: + return Symbols.info; + case ChatPreviewKind.other: + return Symbols.attach_file; + } +} + +const Set _iconWithCaption = { + ChatPreviewKind.photo, + ChatPreviewKind.video, + ChatPreviewKind.videoNote, + ChatPreviewKind.audio, + ChatPreviewKind.file, + ChatPreviewKind.sticker, + ChatPreviewKind.contact, + ChatPreviewKind.location, + ChatPreviewKind.poll, + ChatPreviewKind.call, + ChatPreviewKind.missedCall, + ChatPreviewKind.videoCall, + ChatPreviewKind.missedVideoCall, +}; + +class ChatPreviewLine extends StatelessWidget { + final String prefix; + final String text; + final List ranges; + final ChatPreviewMedia? media; + final TextStyle style; + final bool italic; + + const ChatPreviewLine({ + super.key, + required this.text, + required this.style, + this.prefix = '', + this.ranges = const [], + this.media, + this.italic = false, + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final preview = media; + final label = preview?.label; + final detail = preview?.detail; + final labelled = label != null || detail != null; + final forwarded = label != null && label.startsWith(_forwardMark); + + final bodyStyle = style.copyWith( + fontStyle: italic || labelled ? FontStyle.italic : style.fontStyle, + ); + + final spans = []; + if (prefix.isNotEmpty) spans.add(TextSpan(text: prefix, style: style)); + if (forwarded) spans.add(TextSpan(text: _forwardMark, style: bodyStyle)); + + final leading = _leading( + cs, + preview, + labelled: labelled, + gap: detail == null ? 4 : 0, + ); + if (leading != null) { + spans.add( + WidgetSpan(alignment: PlaceholderAlignment.middle, child: leading), + ); + } + + if (labelled) { + final body = detail != null + ? ': $detail' + : label!.substring(forwarded ? _forwardMark.length : 0); + spans.add(TextSpan(text: body, style: bodyStyle)); + } else { + spans.addAll( + FormattedMessageText.buildInlineChildren(text, ranges, bodyStyle), + ); + } + + return Text.rich( + TextSpan(style: bodyStyle, children: spans), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ); + } + + Widget? _leading( + ColorScheme cs, + ChatPreviewMedia? preview, { + required bool labelled, + required double gap, + }) { + if (preview == null) return null; + final size = (style.fontSize ?? 14) + 2; + if (preview.thumbs.isNotEmpty) { + return Padding( + padding: EdgeInsets.only(right: gap), + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: 2, + children: [ + for (final thumb in preview.thumbs) + _PreviewThumb(thumb: thumb, size: size), + ], + ), + ); + } + if (!labelled && !_iconWithCaption.contains(preview.kind)) return null; + return Padding( + padding: EdgeInsets.only(right: gap), + child: Icon( + previewKindIcon(preview.kind), + size: size, + color: cs.outline, + weight: 500, + ), + ); + } +} + +class _PreviewThumb extends StatelessWidget { + final ChatPreviewThumb thumb; + final double size; + + const _PreviewThumb({required this.thumb, required this.size}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final provider = _provider(); + return ClipRRect( + borderRadius: BorderRadius.circular(3), + child: SizedBox( + width: size, + height: size, + child: Stack( + fit: StackFit.expand, + children: [ + ColoredBox(color: cs.surfaceContainerHighest), + if (provider != null) + Image(image: provider, fit: BoxFit.cover, gaplessPlayback: true), + if (thumb.video) + Center( + child: Icon( + Symbols.play_arrow, + size: size * 0.7, + fill: 1, + color: Colors.white, + shadows: const [Shadow(color: Colors.black54, blurRadius: 2)], + ), + ), + ], + ), + ), + ); + } + + ImageProvider? _provider() => _thumbProvider(thumb.source); +} + +const int _thumbCacheLimit = 128; +final Map _thumbCache = {}; + +ImageProvider? _thumbProvider(String source) { + final cached = _thumbCache[source]; + if (cached != null) return cached; + final ImageProvider? provider; + if (source.startsWith('data:')) { + provider = _decodeDataUri(source); + } else if (source.startsWith('http')) { + provider = CachedNetworkImageProvider(source, maxWidth: 64, maxHeight: 64); + } else { + provider = null; + } + if (provider == null) return null; + if (_thumbCache.length >= _thumbCacheLimit) { + _thumbCache.remove(_thumbCache.keys.first); + } + _thumbCache[source] = provider; + return provider; +} + +ImageProvider? _decodeDataUri(String source) { + final comma = source.indexOf(','); + if (comma < 0) return null; + try { + return MemoryImage(base64Decode(source.substring(comma + 1))); + } catch (_) { + return null; + } +} diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index c4d0edd..f01b6a3 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -22,7 +22,6 @@ import '../../widgets/sheet_helpers.dart'; import '../../widgets/swipe_route.dart'; import '../../widgets/sliding_pill_nav.dart'; import '../../widgets/springy_tap.dart'; -import '../../widgets/formatted_message_text.dart'; import '../../widgets/informer_banner_tile.dart'; import '../../../core/utils/format.dart'; import '../../../core/utils/download_history.dart'; @@ -30,6 +29,7 @@ import '../../../core/utils/link_opener.dart'; import '../../../core/utils/text_format.dart'; import '../../../core/utils/update_checker.dart'; import '../../../l10n/app_localizations.dart'; +import '../../../models/chat_preview_media.dart'; import '../../../models/informer_banner.dart'; import '../calls/calls_tab.dart'; @@ -40,6 +40,7 @@ import '../digital_id/digital_id_web_screen.dart'; import '../../widgets/account_switcher_overlay.dart'; import 'chat/view/chat_list_shimmer.dart'; import 'chat/view/chat_list_tile.dart'; +import 'chat/view/chat_preview_line.dart'; import '../../widgets/connection_status.dart'; import '../../../backend/api.dart'; import '../../../core/protocol/opcode_map.dart'; @@ -1888,6 +1889,11 @@ class _ChatListScreenState extends State previewCipherText: isPlaceholder ? null : chat.lastMsgTextOneLine, + previewMedia: isPlaceholder ? null : chat.lastMsgMedia, + titleIcon: chatKindIcon( + 'DIALOG', + isBot: _isBotDialog(secondId, chat), + ), hasMiniApp: _hasMiniApp(secondId, chat), ), ); @@ -1897,42 +1903,22 @@ class _ChatListScreenState extends State ? ContactCache.get(chat.lastMsgSenderId!) : null; - String fullMsg = ""; - String senderPrefix = ""; - List messageRanges = const []; - if (isPlaceholder) { - fullMsg = 'зайдите в чат для подгрузки'; - } else { - var prefixLen = 0; - if (sender?.isNotEmpty == true && chat.id != 0) { - final prefix = "$sender: "; - senderPrefix = prefix; - fullMsg += prefix; - prefixLen = prefix.length; - } - if (chat.lastMsgText?.isNotEmpty == true) { - fullMsg += chat.lastMsgText ?? ""; - final ranges = chat.lastMsgFormatRanges; - messageRanges = prefixLen == 0 - ? ranges - : [ - for (final r in ranges) - FormatRange( - format: r.format, - start: r.start + prefixLen, - length: r.length, - attributes: r.attributes, - ), - ]; - } - } + final senderPrefix = + !isPlaceholder && + sender?.isNotEmpty == true && + chat.id != 0 + ? "$sender: " + : ""; + final body = isPlaceholder + ? 'зайдите в чат для подгрузки' + : (chat.lastMsgTextOneLine ?? ''); return _animateChatTile( chat.id.toString(), _buildChatItem( chat.id.toString(), chat.id == 0 ? "Избранное" : chat.title ?? "Чат", - fullMsg, + body, _formatTime(chat.lastMsgTime), (chat.iconUrl != null && chat.iconUrl!.isNotEmpty) ? chat.iconUrl! @@ -1947,12 +1933,18 @@ class _ChatListScreenState extends State draft: chat.id == 0 ? null : _draftFor(chat.id), ownStatus: _ownStatusFor(chat, isPlaceholder), ownRead: chat.lastMsgReadByOthers, - messageRanges: messageRanges, + messageRanges: isPlaceholder + ? const [] + : chat.lastMsgFormatRanges, previewMessageId: isPlaceholder ? null : chat.lastMsgId, previewPrefix: senderPrefix, previewCipherText: isPlaceholder ? null : chat.lastMsgText, + previewMedia: isPlaceholder ? null : chat.lastMsgMedia, + titleIcon: chat.id == 0 + ? null + : chatKindIcon(chat.type, isBot: false), ), ); } @@ -2729,8 +2721,10 @@ class _ChatListScreenState extends State String message, List messageRanges, String? draft, - bool messageItalic, - ) { + bool messageItalic, { + String prefix = '', + ChatPreviewMedia? media, + }) { if (draft != null) { return Text.rich( TextSpan( @@ -2755,29 +2749,18 @@ class _ChatListScreenState extends State overflow: TextOverflow.ellipsis, ); } - final previewStyle = TextStyle( - color: cs.outline, - fontSize: 14, - fontWeight: FontWeight.w400, - fontStyle: messageItalic ? FontStyle.italic : FontStyle.normal, - height: 1.2, - ); - if (messageRanges.isEmpty) { - return Text( - message, - style: previewStyle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ); - } - return Text.rich( - FormattedMessageText.buildInlineSpan( - message, - messageRanges, - previewStyle, + return ChatPreviewLine( + prefix: prefix, + text: message, + ranges: messageRanges, + media: media, + italic: messageItalic, + style: TextStyle( + color: cs.outline, + fontSize: 14, + fontWeight: FontWeight.w400, + height: 1.2, ), - maxLines: 1, - overflow: TextOverflow.ellipsis, ); } @@ -2824,6 +2807,8 @@ class _ChatListScreenState extends State int? previewMessageId, String previewPrefix = '', String? previewCipherText, + ChatPreviewMedia? previewMedia, + IconData? titleIcon, bool hasMiniApp = false, }) { final cs = Theme.of(context).colorScheme; @@ -2853,25 +2838,36 @@ class _ChatListScreenState extends State messageRanges, draft, messageItalic, + prefix: previewPrefix, + media: previewMedia, ), MessageDecryptionState.wrongKey => _buildPreviewLine( cs, - '$previewPrefix' 'неверный ключ', const [], draft, true, + prefix: previewPrefix, ), MessageDecryptionState.decrypted => _buildPreviewLine( cs, - '$previewPrefix${decryption!.plaintext}', + decryption!.plaintext ?? '', const [], draft, messageItalic, + prefix: previewPrefix, ), }, ) - : _buildPreviewLine(cs, message, messageRanges, draft, messageItalic); + : _buildPreviewLine( + cs, + message, + messageRanges, + draft, + messageItalic, + prefix: previewPrefix, + media: previewMedia, + ); final storyOwnerId = chatType == 'DIALOG' ? presenceUserId @@ -3041,6 +3037,16 @@ class _ChatListScreenState extends State child: Row( mainAxisSize: MainAxisSize.min, children: [ + if (titleIcon != null) ...[ + Icon( + titleIcon, + color: cs.outline, + size: 15, + weight: 500, + fill: 1, + ), + const SizedBox(width: 4), + ], Flexible( child: Text( name, @@ -3151,6 +3157,12 @@ class _ChatListScreenState extends State ); } + bool _isBotDialog(int contactId, CachedChat chat) { + if (contactId == 0 || contactId == _profile?.id) return false; + if (ContactCache.getOptions(contactId)?.contains('BOT') == true) return true; + return chat.options.contains('BOT'); + } + bool _hasMiniApp(int contactId, CachedChat chat) { if (contactId == 0 || widget.forwardMode || _isSelectionMode) return false; final options = ContactCache.getOptions(contactId); diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 798aadc..b0805b8 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -299,6 +299,7 @@ class _ChatScreenState extends State bool _keyboardBeforeStickers = false; final ScrollController _scrollController = ScrollController(); bool _userDidScroll = false; + int _userGestureEpoch = 0; String? _pinnedMessageId; double _pinnedAlignment = 0; int? _unreadAnchorTime; @@ -1638,35 +1639,57 @@ class _ChatScreenState extends State void _maybeFillGap() { final controller = _chatController; if (!controller.hasGap || controller.loadingGap) return; + final oldestRendered = _oldestRenderedMessageTime(); for (final gap in controller.gaps) { - final box = _keyForMessage(gap.edgeId).currentContext?.findRenderObject(); - if (box is RenderBox && box.attached) { - unawaited(_fillGapForward(gap)); - return; + if (!ChatController.gapFillLeavesViewportInPlace(gap, oldestRendered)) { + continue; } + unawaited(_fillGapForward(gap)); + return; } } + int? _oldestRenderedMessageTime() { + for (final message in _messages) { + final box = _messageKeys[message.id]?.currentContext?.findRenderObject(); + if (box is RenderBox && box.attached) return message.time; + } + return null; + } + Future _fillGapForward(HistoryGap gap) async { - final edgeId = gap.edgeId; - final beforeDy = _messageOffsetInList(edgeId); - final added = await _chatController.fillGapForward(gap); + String? anchorId; + double? anchorAt; + double? anchorAlignment; + final added = await _chatController.fillGapForward( + gap, + beforeApply: () { + final id = _viewportAnchorId(); + anchorId = id; + if (id == null) return; + anchorAt = _messageContentOffset(id); + anchorAlignment = _messageAlignmentInList(id); + }, + ); if (!mounted || added == 0) return; _syncReactionNotifiersFromMessages(); _bumpMessages(); await WidgetsBinding.instance.endOfFrame; - if (!mounted || !_scrollController.hasClients) return; + if (!mounted) return; - final afterDy = _messageOffsetInList(edgeId); - if (beforeDy != null && afterDy != null) { - final delta = beforeDy - afterDy; - if (delta.abs() > 0.5) { - final pos = _scrollController.position; - _scrollController.jumpTo( - (pos.pixels + delta).clamp(pos.minScrollExtent, pos.maxScrollExtent), - ); - } + final id = anchorId; + final at = anchorAt; + final alignment = anchorAlignment; + if (id != null && at != null && !_restoreContentOffset(id, at)) { + _historyAutoloadSuppressCount++; + _alignLoadedMessage( + id, + alignment ?? 0, + 0, + epoch: _userGestureEpoch, + onSettled: () => _historyAutoloadSuppressCount--, + ); } _loadForwardedSenderNames(); _loadGroupSenderNames(); @@ -1689,36 +1712,28 @@ class _ChatScreenState extends State final listBox = _listKey.currentContext?.findRenderObject(); if (listBox is! RenderBox || !listBox.attached) return null; final height = listBox.size.height; + String? newest; for (final message in _messages) { final box = _messageKeys[message.id]?.currentContext?.findRenderObject(); if (box is! RenderBox || !box.attached) continue; final dy = box.localToGlobal(Offset.zero, ancestor: listBox).dy; - if (dy >= 0 && dy <= height) return message.id; + if (dy >= 0 && dy <= height) newest = message.id; } - return null; + return newest; } Future _holdScrollAfterAppend( String? anchorId, - double? beforeDy, + double? anchorAt, ) async { - if (anchorId == null || beforeDy == null) return; + if (anchorId == null || anchorAt == null) return; await WidgetsBinding.instance.endOfFrame; if (!mounted || !_scrollController.hasClients) return; - - final afterDy = _messageOffsetInList(anchorId); - if (afterDy == null) return; - final delta = beforeDy - afterDy; - if (delta.abs() <= 0.5) return; - - final pos = _scrollController.position; - if (pos.userScrollDirection != ScrollDirection.idle) return; - final target = (pos.pixels + delta).clamp( - pos.minScrollExtent, - pos.maxScrollExtent, - ); - if ((target - pos.pixels).abs() <= 0.5) return; - _scrollController.jumpTo(target); + if (_scrollController.position.userScrollDirection != + ScrollDirection.idle) { + return; + } + _restoreContentOffset(anchorId, anchorAt); } double? _messageOffsetInList(String messageId) { @@ -1730,6 +1745,37 @@ class _ChatScreenState extends State return box.localToGlobal(Offset.zero, ancestor: listBox).dy; } + double? _messageContentOffset(String messageId) { + if (!_scrollController.hasClients) return null; + final dy = _messageOffsetInList(messageId); + if (dy == null) return null; + return dy - _scrollController.position.pixels; + } + + double? _messageAlignmentInList(String messageId) { + final listBox = _listKey.currentContext?.findRenderObject(); + if (listBox is! RenderBox || listBox.size.height <= 0) return null; + final dy = _messageOffsetInList(messageId); + if (dy == null) return null; + return (dy / listBox.size.height).clamp(0.0, 1.0); + } + + bool _restoreContentOffset(String messageId, double before) { + if (!_scrollController.hasClients) return false; + final after = _messageContentOffset(messageId); + if (after == null) return false; + final delta = before - after; + if (delta.abs() <= 0.5) return true; + final pos = _scrollController.position; + final target = (pos.pixels + delta).clamp( + pos.minScrollExtent, + pos.maxScrollExtent, + ); + if ((target - pos.pixels).abs() <= 0.5) return true; + _scrollController.jumpTo(target); + return true; + } + Future _loadMessageWindow(String messageId, int targetTime) async { if (targetTime <= 0) { await _walkHistoryBack( @@ -1972,7 +2018,7 @@ class _ChatScreenState extends State if (_messages.any((m) => m.id == comment.id)) return; final nearBottom = _isNearListBottom(); final anchorId = nearBottom ? null : _viewportAnchorId(); - final anchorDy = anchorId == null ? null : _messageOffsetInList(anchorId); + final anchorAt = anchorId == null ? null : _messageContentOffset(anchorId); if (!nearBottom) _retainOffsetForNextLayout(); _messages.add(comment); _syncReactionNotifiersFromMessages(); @@ -1982,7 +2028,7 @@ class _ChatScreenState extends State _scrollToBottom(); } else { _noteMissedMessage(); - unawaited(_holdScrollAfterAppend(anchorId, anchorDy)); + unawaited(_holdScrollAfterAppend(anchorId, anchorAt)); } } @@ -3010,9 +3056,9 @@ class _ChatScreenState extends State if (_messages.any((m) => m.id == message.id)) return; final nearBottom = _isNearBottom(); final anchorId = nearBottom ? null : _viewportAnchorId(); - final anchorDy = anchorId == null + final anchorAt = anchorId == null ? null - : _messageOffsetInList(anchorId); + : _messageContentOffset(anchorId); if (!nearBottom) _retainOffsetForNextLayout(); _lastSentId = message.id; _messages.add(message); @@ -3024,7 +3070,7 @@ class _ChatScreenState extends State _scheduleReadMarker(); } else { _noteMissedMessage(); - unawaited(_holdScrollAfterAppend(anchorId, anchorDy)); + unawaited(_holdScrollAfterAppend(anchorId, anchorAt)); _reapplyPinIfNeeded(); } _prank.checkTrigger(message); @@ -4797,11 +4843,13 @@ class _ChatScreenState extends State if (!mounted || !_scrollController.hasClients) return; if (_messages.indexWhere((m) => m.id == id) == -1) return; + final epoch = _userGestureEpoch; _historyAutoloadSuppressCount++; try { var stable = 0; for (var iter = 0; iter < 120; iter++) { if (!mounted || !_scrollController.hasClients) return; + if (_userGestureEpoch != epoch) return; final listObj = _listKey.currentContext?.findRenderObject(); final boxObj = _keyForMessage(id).currentContext?.findRenderObject(); final p = _scrollController.position; @@ -4986,9 +5034,12 @@ class _ChatScreenState extends State double alignment, int attempt, { int frames = 0, + int? epoch, VoidCallback? onSettled, }) { - if (!mounted || !_scrollController.hasClients) { + if (!mounted || + !_scrollController.hasClients || + (epoch != null && epoch != _userGestureEpoch)) { onSettled?.call(); return; } @@ -5006,6 +5057,7 @@ class _ChatScreenState extends State alignment, moved ? 0 : attempt + 1, frames: frames + 1, + epoch: epoch, onSettled: onSettled, ), ); @@ -5038,6 +5090,7 @@ class _ChatScreenState extends State alignment, attempt + 1, frames: frames + 1, + epoch: epoch, onSettled: onSettled, ), ); @@ -5575,9 +5628,14 @@ class _ChatScreenState extends State children: [ Opacity( opacity: showShimmer ? 0.0 : 1.0, - child: NotificationListener( - onNotification: (_) { - _updateReadMarker(); + child: NotificationListener( + onNotification: (notification) { + if (notification is ScrollStartNotification && + notification.dragDetails != null) { + _userGestureEpoch++; + } else if (notification is ScrollEndNotification) { + _updateReadMarker(); + } return false; }, child: _buildMessagesList(), diff --git a/lib/frontend/widgets/formatted_message_text.dart b/lib/frontend/widgets/formatted_message_text.dart index 03d8a62..ad0827e 100644 --- a/lib/frontend/widgets/formatted_message_text.dart +++ b/lib/frontend/widgets/formatted_message_text.dart @@ -46,24 +46,66 @@ class FormattedMessageText extends StatefulWidget { List ranges, TextStyle style, { Color? mentionColor, + }) => TextSpan( + style: style, + children: buildInlineChildren( + text, + ranges, + style, + mentionColor: mentionColor, + ), + ); + + static List buildInlineChildren( + String text, + List ranges, + TextStyle style, { + Color? mentionColor, }) { final quoteColor = style.color?.withValues(alpha: 0.85); - final segments = segmentizeFormats(text, ranges); - return TextSpan( - style: style, - children: [ - for (final segment in segments) - TextSpan( - text: text.substring(segment.start, segment.end), - style: applyTextFormats( - style, - segment.formats, - quoteColor: quoteColor, - mentionColor: mentionColor, + final fontSize = style.fontSize ?? 16; + final spans = []; + for (final segment in segmentizeFormats(text, ranges)) { + final content = text.substring(segment.start, segment.end); + final animojiUrl = segment.animojiUrl; + if (animojiUrl != null) { + final box = fontSize * 1.35; + spans.add( + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: SizedBox( + width: box, + height: box, + child: Stack( + alignment: Alignment.center, + children: [ + Text(content, style: style.copyWith(fontSize: fontSize)), + LottieImage( + lottieUrl: animojiUrl, + size: box, + memCacheWidth: 96, + shimmer: false, + ), + ], + ), ), ), - ], - ); + ); + continue; + } + spans.add( + TextSpan( + text: content, + style: applyTextFormats( + style, + segment.formats, + quoteColor: quoteColor, + mentionColor: mentionColor, + ), + ), + ); + } + return spans; } @override diff --git a/lib/models/chat_preview_media.dart b/lib/models/chat_preview_media.dart new file mode 100644 index 0000000..7af6107 --- /dev/null +++ b/lib/models/chat_preview_media.dart @@ -0,0 +1,108 @@ +import 'dart:convert'; + +enum ChatPreviewKind { + photo, + video, + videoNote, + audio, + file, + sticker, + contact, + location, + poll, + share, + call, + missedCall, + videoCall, + missedVideoCall, + control, + other, +} + +const Map _kindToCode = { + ChatPreviewKind.photo: 'photo', + ChatPreviewKind.video: 'video', + ChatPreviewKind.videoNote: 'videoNote', + ChatPreviewKind.audio: 'audio', + ChatPreviewKind.file: 'file', + ChatPreviewKind.sticker: 'sticker', + ChatPreviewKind.contact: 'contact', + ChatPreviewKind.location: 'location', + ChatPreviewKind.poll: 'poll', + ChatPreviewKind.share: 'share', + ChatPreviewKind.call: 'call', + ChatPreviewKind.missedCall: 'missedCall', + ChatPreviewKind.videoCall: 'videoCall', + ChatPreviewKind.missedVideoCall: 'missedVideoCall', + ChatPreviewKind.control: 'control', + ChatPreviewKind.other: 'other', +}; + +final Map _codeToKind = { + for (final e in _kindToCode.entries) e.value: e.key, +}; + +class ChatPreviewThumb { + final String source; + final bool video; + + const ChatPreviewThumb({required this.source, this.video = false}); + + Map toMap() => {'s': source, if (video) 'v': true}; + + static ChatPreviewThumb? fromMap(dynamic raw) { + if (raw is! Map) return null; + final source = raw['s']?.toString(); + if (source == null || source.isEmpty) return null; + return ChatPreviewThumb(source: source, video: raw['v'] == true); + } +} + +class ChatPreviewMedia { + final ChatPreviewKind kind; + final List thumbs; + final String? label; + final String? detail; + + const ChatPreviewMedia({ + required this.kind, + this.thumbs = const [], + this.label, + this.detail, + }); + + bool get captioned => label == null && detail == null; + + Map toMap() => { + 'k': _kindToCode[kind], + if (thumbs.isNotEmpty) 't': [for (final thumb in thumbs) thumb.toMap()], + if (label != null) 'l': label, + if (detail != null) 'd': detail, + }; + + String encode() => jsonEncode(toMap()); + + static ChatPreviewMedia? decode(String? raw) { + if (raw == null || raw.isEmpty) return null; + try { + return fromMap(jsonDecode(raw)); + } catch (_) { + return null; + } + } + + static ChatPreviewMedia? fromMap(dynamic raw) { + if (raw is! Map) return null; + final kind = _codeToKind[raw['k']?.toString()]; + if (kind == null) return null; + final thumbsRaw = raw['t']; + return ChatPreviewMedia( + kind: kind, + thumbs: thumbsRaw is List + ? [for (final item in thumbsRaw) ?ChatPreviewThumb.fromMap(item)] + : const [], + label: raw['l']?.toString(), + detail: raw['d']?.toString(), + ); + } +} diff --git a/test/chat_preview_line_test.dart b/test/chat_preview_line_test.dart new file mode 100644 index 0000000..6efc52d --- /dev/null +++ b/test/chat_preview_line_test.dart @@ -0,0 +1,196 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/frontend/screens/chats/chat/view/chat_preview_line.dart'; +import 'package:komet/models/chat_preview_media.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +const String _pixel = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ' + 'AAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='; + +const TextStyle _style = TextStyle(fontSize: 14, height: 1.2); + +Widget _host(Widget child) => MaterialApp( + home: Scaffold(body: Center(child: child)), +); + +String _plainText(WidgetTester tester) { + final span = tester.widget(find.byType(Text).first).textSpan!; + final buffer = StringBuffer(); + span.visitChildren((child) { + if (child is TextSpan && child.text != null) buffer.write(child.text); + return true; + }); + return buffer.toString(); +} + +List _spans(WidgetTester tester) { + final span = tester.widget(find.byType(Text).first).textSpan!; + final result = []; + span.visitChildren((child) { + if (child is TextSpan && child.text != null) result.add(child); + return true; + }); + return result; +} + +void main() { + testWidgets('фото без подписи: миниатюра и курсивная подпись', ( + tester, + ) async { + await tester.pumpWidget( + _host( + const ChatPreviewLine( + text: 'Изображение', + style: _style, + media: ChatPreviewMedia( + kind: ChatPreviewKind.photo, + thumbs: [ChatPreviewThumb(source: _pixel)], + label: 'Изображение', + ), + ), + ), + ); + + expect(find.byType(Image), findsOneWidget); + expect(find.byIcon(Symbols.play_arrow), findsNothing); + expect(_plainText(tester), 'Изображение'); + expect(_spans(tester).single.style?.fontStyle, FontStyle.italic); + }); + + testWidgets('фото с подписью: миниатюра и обычный текст', (tester) async { + await tester.pumpWidget( + _host( + const ChatPreviewLine( + prefix: 'Кто-то: ', + text: 'смотри', + style: _style, + media: ChatPreviewMedia( + kind: ChatPreviewKind.photo, + thumbs: [ChatPreviewThumb(source: _pixel)], + ), + ), + ), + ); + + expect(find.byType(Image), findsOneWidget); + expect(_plainText(tester), 'Кто-то: смотри'); + expect(_spans(tester).last.style?.fontStyle, isNot(FontStyle.italic)); + }); + + testWidgets('видео помечается иконкой проигрывания на миниатюре', ( + tester, + ) async { + await tester.pumpWidget( + _host( + const ChatPreviewLine( + text: 'Видео', + style: _style, + media: ChatPreviewMedia( + kind: ChatPreviewKind.video, + thumbs: [ + ChatPreviewThumb(source: _pixel, video: true), + ChatPreviewThumb(source: _pixel), + ], + label: 'Видео', + ), + ), + ), + ); + + expect(find.byType(Image), findsNWidgets(2)); + expect(find.byIcon(Symbols.play_arrow), findsOneWidget); + }); + + testWidgets('файл: иконка вместо слова и имя после двоеточия', ( + tester, + ) async { + await tester.pumpWidget( + _host( + const ChatPreviewLine( + text: 'Файл: notes.pdf', + style: _style, + media: ChatPreviewMedia( + kind: ChatPreviewKind.file, + label: 'Файл', + detail: 'notes.pdf', + ), + ), + ), + ); + + expect(find.byIcon(Symbols.description), findsOneWidget); + expect(_plainText(tester), ': notes.pdf'); + }); + + testWidgets('специфичная подпись идёт с иконкой и курсивом', (tester) async { + await tester.pumpWidget( + _host( + const ChatPreviewLine( + text: 'Пропущенный звонок', + style: _style, + media: ChatPreviewMedia( + kind: ChatPreviewKind.missedCall, + label: 'Пропущенный звонок', + ), + ), + ), + ); + + expect(find.byIcon(Symbols.call_missed), findsOneWidget); + expect(_plainText(tester), 'Пропущенный звонок'); + expect(_spans(tester).single.style?.fontStyle, FontStyle.italic); + }); + + testWidgets('метка пересылки остаётся перед иконкой', (tester) async { + await tester.pumpWidget( + _host( + const ChatPreviewLine( + text: '↪ Контакт', + style: _style, + media: ChatPreviewMedia( + kind: ChatPreviewKind.contact, + label: '↪ Контакт', + ), + ), + ), + ); + + expect(find.byIcon(Symbols.person), findsOneWidget); + expect(_plainText(tester), '↪ Контакт'); + }); + + testWidgets('ссылка с текстом сообщения не тащит иконку', (tester) async { + await tester.pumpWidget( + _host( + const ChatPreviewLine( + text: 'глянь komet.ru', + style: _style, + media: ChatPreviewMedia(kind: ChatPreviewKind.share), + ), + ), + ); + + expect(find.byIcon(Symbols.link), findsNothing); + expect(_plainText(tester), 'глянь komet.ru'); + }); + + testWidgets('без описания вложения строка остаётся обычным текстом', ( + tester, + ) async { + await tester.pumpWidget( + _host(const ChatPreviewLine(text: 'привет', style: _style)), + ); + + expect(find.byType(Image), findsNothing); + expect(_plainText(tester), 'привет'); + }); + + test('иконка типа чата зависит от вида чата', () { + expect(chatKindIcon('CHANNEL', isBot: false), Symbols.campaign); + expect(chatKindIcon('CHAT', isBot: false), Symbols.group); + expect(chatKindIcon('GROUP', isBot: false), Symbols.group); + expect(chatKindIcon('DIALOG', isBot: true), Symbols.smart_toy); + expect(chatKindIcon('DIALOG', isBot: false), isNull); + }); +} diff --git a/test/chat_preview_media_test.dart b/test/chat_preview_media_test.dart new file mode 100644 index 0000000..3a4e90b --- /dev/null +++ b/test/chat_preview_media_test.dart @@ -0,0 +1,186 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/backend/modules/chat_preview.dart'; +import 'package:komet/models/chat_preview_media.dart'; + +const String _thumbA = 'data:image/webp;base64,AAAA'; +const String _thumbB = 'data:image/webp;base64,BBBB'; +const String _thumbC = 'data:image/webp;base64,CCCC'; +const String _thumbD = 'data:image/webp;base64,DDDD'; + +Map _photo(String preview) => { + '_type': 'PHOTO', + 'previewData': preview, + 'photoId': 1, +}; + +Map _video(String preview) => { + '_type': 'VIDEO', + 'previewData': preview, + 'videoId': 2, +}; + +ChatPreviewMedia _media(Map msg) { + final encoded = messagePreviewMedia(msg); + expect(encoded, isNotNull); + final decoded = ChatPreviewMedia.decode(encoded); + expect(decoded, isNotNull); + return decoded!; +} + +void main() { + group('превью вложений', () { + test('одиночное фото без подписи даёт миниатюру и словесную подпись', () { + final msg = { + 'text': '', + 'attaches': [_photo(_thumbA)], + }; + final media = _media(msg); + expect(media.kind, ChatPreviewKind.photo); + expect(media.captioned, isFalse); + expect(media.label, 'Изображение'); + expect(media.detail, isNull); + expect(media.thumbs.map((t) => t.source), [_thumbA]); + expect(media.thumbs.single.video, isFalse); + expect(messagePreviewText(msg), 'Изображение'); + }); + + test('фото с подписью оставляет текст сообщения', () { + final msg = { + 'text': 'подпись', + 'attaches': [_photo(_thumbA)], + }; + final media = _media(msg); + expect(media.captioned, isTrue); + expect(media.label, isNull); + expect(media.thumbs, hasLength(1)); + expect(messagePreviewText(msg), 'подпись'); + }); + + test('альбом отдаёт не больше трёх миниатюр и помечает видео', () { + final msg = { + 'text': '', + 'attaches': [ + _photo(_thumbA), + _video(_thumbB), + _photo(_thumbC), + _photo(_thumbD), + ], + }; + final media = _media(msg); + expect(media.label, 'Изображения'); + expect(media.thumbs.map((t) => t.source), [_thumbA, _thumbB, _thumbC]); + expect(media.thumbs.map((t) => t.video), [false, true, false]); + }); + + test('кружок не считается обычным видео', () { + final msg = { + 'text': '', + 'attaches': [ + {'_type': 'VIDEO', 'videoType': 1, 'previewData': _thumbA}, + ], + }; + final media = _media(msg); + expect(media.kind, ChatPreviewKind.videoNote); + expect(media.label, 'Видео-сообщение'); + }); + + test('файл отдаёт имя отдельно от подписи', () { + final msg = { + 'text': '', + 'attaches': [ + {'_type': 'FILE', 'name': 'notes.pdf', 'fileId': 3}, + ], + }; + final media = _media(msg); + expect(media.kind, ChatPreviewKind.file); + expect(media.label, 'Файл'); + expect(media.detail, 'notes.pdf'); + expect(media.thumbs, isEmpty); + expect(messagePreviewText(msg), 'Файл: notes.pdf'); + }); + + test('пропущенный звонок отличается от состоявшегося', () { + final missed = _media({ + 'text': '', + 'attaches': [ + {'_type': 'CALL', 'callType': 'AUDIO', 'duration': 0}, + ], + }); + expect(missed.kind, ChatPreviewKind.missedCall); + expect(missed.label, 'Пропущенный звонок'); + + final answered = _media({ + 'text': '', + 'attaches': [ + {'_type': 'CALL', 'callType': 'VIDEO', 'duration': 42}, + ], + }); + expect(answered.kind, ChatPreviewKind.videoCall); + expect(answered.label, 'Видеозвонок'); + }); + + test('пересланное вложение сохраняет метку пересылки', () { + final msg = { + 'text': '', + 'link': { + 'type': 'FORWARD', + 'message': { + 'text': '', + 'attaches': [_photo(_thumbA)], + }, + }, + }; + final media = _media(msg); + expect(media.kind, ChatPreviewKind.photo); + expect(media.label, '↪ Изображение'); + expect(media.thumbs, hasLength(1)); + expect(messagePreviewText(msg), '↪ Изображение'); + }); + + test('клавиатура бота не считается вложением', () { + final encoded = messagePreviewMedia({ + 'text': 'выбери вариант', + 'attaches': [ + {'_type': 'INLINE_KEYBOARD'}, + ], + }); + expect(encoded, isNull); + }); + + test('без вложений описания нет', () { + expect(messagePreviewMedia({'text': 'привет'}), isNull); + }); + + test('слишком тяжёлая миниатюра не попадает в кеш чатов', () { + final heavy = 'data:image/webp;base64,${'A' * 30000}'; + final media = _media({ + 'text': '', + 'attaches': [_photo(heavy)], + }); + expect(media.thumbs, isEmpty); + }); + + test('описание переживает сериализацию', () { + const media = ChatPreviewMedia( + kind: ChatPreviewKind.video, + thumbs: [ + ChatPreviewThumb(source: _thumbA, video: true), + ChatPreviewThumb(source: _thumbB), + ], + label: 'Видео', + ); + final restored = ChatPreviewMedia.decode(media.encode())!; + expect(restored.kind, ChatPreviewKind.video); + expect(restored.label, 'Видео'); + expect(restored.detail, isNull); + expect(restored.thumbs.map((t) => t.source), [_thumbA, _thumbB]); + expect(restored.thumbs.map((t) => t.video), [true, false]); + }); + + test('битое описание не роняет разбор', () { + expect(ChatPreviewMedia.decode('{'), isNull); + expect(ChatPreviewMedia.decode('{"k":"чтоэто"}'), isNull); + expect(ChatPreviewMedia.decode(null), isNull); + }); + }); +} diff --git a/test/chat_scroll_anchor_test.dart b/test/chat_scroll_anchor_test.dart index fc8b435..b5cc5a3 100644 --- a/test/chat_scroll_anchor_test.dart +++ b/test/chat_scroll_anchor_test.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/frontend/screens/chats/chat/chat_controller.dart'; import 'package:komet/frontend/screens/chats/chat/retain_offset_physics.dart'; const double _itemHeight = 60; @@ -20,11 +21,10 @@ class _Harness { final ScrollPhysics? physics; final GlobalKey listKey = GlobalKey(); final ScrollController controller = ScrollController(); - final List items = [for (var i = 0; i < 40; i++) 'm$i']; - late final Map keys = { - for (final id in items) id: GlobalKey(), - 'newest': GlobalKey(), - }; + final List items = [for (var i = 0; i < 200; i++) 'm$i']; + final Map keys = {}; + + GlobalKey keyFor(String id) => keys.putIfAbsent(id, GlobalKey.new); Future pump() async { await tester.pumpWidget( @@ -43,7 +43,7 @@ class _Harness { if (index == 0) return const SizedBox(height: 0); final id = items[items.length - index]; return SizedBox( - key: keys[id], + key: keyFor(id), height: id == 'newest' ? _newestHeight : _itemHeight, child: Text(id), ); @@ -59,11 +59,82 @@ class _Harness { } String anchorId() => items.firstWhere((id) { - final dy = _offsetInList(listKey, keys[id]!); + final dy = _offsetInList(listKey, keyFor(id)); return dy != null && dy >= 0 && dy <= _viewportHeight; }); - double dyOf(String id) => _offsetInList(listKey, keys[id]!)!; + double dyOf(String id) => _offsetInList(listKey, keyFor(id))!; + + double? dyOrNull(String id) => _offsetInList(listKey, keyFor(id)); + + double contentOffsetOf(String id) => dyOf(id) - controller.position.pixels; + + double alignmentOf(String id) => dyOf(id) / _viewportHeight; + + void insertAt(int index, int count) { + items.insertAll(index, [for (var i = 0; i < count; i++) 'gap$index-$i']); + } + + bool restore(String id, double before) { + final dy = dyOrNull(id); + if (dy == null) return false; + final delta = before - (dy - controller.position.pixels); + if (delta.abs() <= 0.5) return true; + final pos = controller.position; + final target = (pos.pixels + delta).clamp( + pos.minScrollExtent, + pos.maxScrollExtent, + ); + if ((target - pos.pixels).abs() <= 0.5) return true; + controller.jumpTo(target); + return true; + } + + int visibleOldestIndex() { + for (var i = 0; i < items.length; i++) { + final dy = dyOrNull(items[i]); + if (dy != null && dy + _itemHeight > 0 && dy < _viewportHeight) return i; + } + return -1; + } + + bool jumpNear(String id) { + final index = items.indexOf(id); + final oldest = visibleOldestIndex(); + if (index == -1 || oldest == -1) return false; + final perScreen = (_viewportHeight / _itemHeight).floor(); + final away = (oldest - index).abs(); + final screens = (away / perScreen).clamp(1.0, 4.0); + final pos = controller.position; + final step = pos.viewportDimension * screens; + final next = index < oldest ? pos.pixels + step : pos.pixels - step; + final clamped = next.clamp(pos.minScrollExtent, pos.maxScrollExtent); + if ((clamped - pos.pixels).abs() < 0.5) return false; + controller.jumpTo(clamped); + return true; + } + + Future align(String id, double alignment) async { + for (var frame = 0; frame < 40; frame++) { + final dy = dyOrNull(id); + if (dy == null) { + if (!jumpNear(id)) return frame; + await tester.pump(); + continue; + } + final delta = alignment * _viewportHeight - dy; + if (delta.abs() <= 0.5) return frame; + final pos = controller.position; + final target = (pos.pixels + delta).clamp( + pos.minScrollExtent, + pos.maxScrollExtent, + ); + if ((target - pos.pixels).abs() <= 0.5) return frame; + controller.jumpTo(target); + await tester.pump(); + } + return -1; + } } void main() { @@ -138,4 +209,158 @@ void main() { expect(h.dyOf(anchor), lessThan(beforeDy - 1)); expect(h.controller.position.pixels, 600); }); + + testWidgets('вставка старее вьюпорта не двигает его вообще', (tester) async { + final h = _Harness(tester, physics: null); + addTearDown(h.controller.dispose); + + await h.pump(); + h.controller.jumpTo(600); + await tester.pump(); + + final anchor = h.anchorId(); + final beforeDy = h.dyOf(anchor); + + h.insertAt(10, 60); + await h.pump(); + + expect(h.dyOf(anchor), closeTo(beforeDy, 0.5)); + expect(h.controller.position.pixels, 600); + }); + + testWidgets('заполнение дыры не двигает то, что новее её', (tester) async { + final h = _Harness(tester, physics: null); + addTearDown(h.controller.dispose); + + await h.pump(); + h.controller.jumpTo(600); + await tester.pump(); + + final anchor = h.anchorId(); + final before = h.contentOffsetOf(anchor); + final beforeDy = h.dyOf(anchor); + + h.insertAt(10, 5); + await h.pump(); + h.restore(anchor, before); + await tester.pump(); + + expect(h.dyOf(anchor), closeTo(beforeDy, 0.5)); + expect(h.controller.position.pixels, 600); + }); + + testWidgets('заполнение дыры удерживает то, что старее её', (tester) async { + final h = _Harness(tester, physics: null); + addTearDown(h.controller.dispose); + + await h.pump(); + h.controller.jumpTo(600); + await tester.pump(); + + final anchor = h.anchorId(); + final before = h.contentOffsetOf(anchor); + final beforeDy = h.dyOf(anchor); + + h.insertAt(195, 5); + await h.pump(); + expect(h.dyOf(anchor), lessThan(beforeDy - 1)); + + h.restore(anchor, before); + await tester.pump(); + + expect(h.dyOf(anchor), closeTo(beforeDy, 0.5)); + expect(h.controller.position.pixels, 600 + 5 * _itemHeight); + }); + + testWidgets('скролл пользователя во время дозагрузки не отменяется', ( + tester, + ) async { + final h = _Harness(tester, physics: null); + addTearDown(h.controller.dispose); + + await h.pump(); + h.controller.jumpTo(600); + await tester.pump(); + + final anchor = h.anchorId(); + final before = h.contentOffsetOf(anchor); + final beforeDy = h.dyOf(anchor); + + h.insertAt(195, 5); + await h.pump(); + h.controller.jumpTo(h.controller.position.pixels + 120); + await tester.pump(); + + h.restore(anchor, before); + await tester.pump(); + + expect(h.dyOf(anchor), closeTo(beforeDy + 120, 0.5)); + expect(h.controller.position.pixels, 600 + 120 + 5 * _itemHeight); + }); + + testWidgets('большой блок уносит якорь за пределы отрисованного окна', ( + tester, + ) async { + final h = _Harness(tester, physics: null); + addTearDown(h.controller.dispose); + + await h.pump(); + h.controller.jumpTo(600); + await tester.pump(); + + final anchor = h.anchorId(); + final before = h.contentOffsetOf(anchor); + + h.insertAt(195, 60); + await h.pump(); + + expect(h.dyOrNull(anchor), isNull); + expect(h.restore(anchor, before), isFalse); + expect(h.controller.position.pixels, 600); + }); + + testWidgets('после большого блока выравнивание возвращает якорь на место', ( + tester, + ) async { + final h = _Harness(tester, physics: null); + addTearDown(h.controller.dispose); + + await h.pump(); + h.controller.jumpTo(600); + await tester.pump(); + + final anchor = h.anchorId(); + final before = h.contentOffsetOf(anchor); + final beforeDy = h.dyOf(anchor); + final alignment = h.alignmentOf(anchor); + + h.insertAt(195, 60); + await h.pump(); + expect(h.restore(anchor, before), isFalse); + + final frames = await h.align(anchor, alignment); + + expect(frames, greaterThanOrEqualTo(0)); + expect(frames, lessThan(10)); + expect(h.dyOf(anchor), closeTo(beforeDy, 0.5)); + expect(h.controller.position.pixels, 600 + 60 * _itemHeight); + }); + + group('дыру можно заполнять только с новой стороны', () { + final gap = HistoryGap(edgeId: 'e', edgeTime: 1100, tailTime: 9000); + + test('пользователь в хвосте — вставка ляжет выше вьюпорта', () { + expect(ChatController.gapFillLeavesViewportInPlace(gap, 9000), isTrue); + expect(ChatController.gapFillLeavesViewportInPlace(gap, 9050), isTrue); + }); + + test('пользователь у закрепа — вставка утащила бы вьюпорт', () { + expect(ChatController.gapFillLeavesViewportInPlace(gap, 1050), isFalse); + expect(ChatController.gapFillLeavesViewportInPlace(gap, 8999), isFalse); + }); + + test('без отрисованных сообщений заполнять нечего', () { + expect(ChatController.gapFillLeavesViewportInPlace(gap, null), isFalse); + }); + }); }