feat/fix: иконки у каналов/групп/ботов, + миниатюры и иконки к видам медиа на экране чатов

This commit is contained in:
Jganenokk
2026-08-10 00:04:16 +07:00
parent 05ca3a9413
commit 8c8c87daaf
13 changed files with 1424 additions and 162 deletions
+19 -2
View File
@@ -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;
+162 -32
View File
@@ -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<Map>()) {
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<ChatPreviewThumb> _previewThumbs(dynamic attaches) {
if (attaches is! List) return const [];
final thumbs = <ChatPreviewThumb>[];
for (final attach in attaches.whereType<Map>()) {
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') {
+20
View File
@@ -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<FormatRange> 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<Map<String, dynamic>>? 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;
}
+10 -1
View File
@@ -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,
@@ -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<int> fillGapForward(HistoryGap gap) async {
Future<int> fillGapForward(
HistoryGap gap, {
void Function()? beforeApply,
}) async {
if (loadingGap || myId == 0 || !gaps.contains(gap)) return 0;
if (gap.edgeTime <= 0 || gap.tailTime <= gap.edgeTime) {
_closeGap(gap);
@@ -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;
@@ -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<ChatPreviewKind> _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<FormatRange> 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 = <InlineSpan>[];
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<String, ImageProvider> _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;
}
}
@@ -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<ChatListScreen>
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<ChatListScreen>
? ContactCache.get(chat.lastMsgSenderId!)
: null;
String fullMsg = "";
String senderPrefix = "";
List<FormatRange> 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<ChatListScreen>
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<ChatListScreen>
String message,
List<FormatRange> 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<ChatListScreen>
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<ChatListScreen>
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<ChatListScreen>
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<ChatListScreen>
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<ChatListScreen>
);
}
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);
+102 -44
View File
@@ -299,6 +299,7 @@ class _ChatScreenState extends State<ChatScreen>
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<ChatScreen>
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<void> _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<ChatScreen>
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<void> _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<ChatScreen>
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<void> _loadMessageWindow(String messageId, int targetTime) async {
if (targetTime <= 0) {
await _walkHistoryBack(
@@ -1972,7 +2018,7 @@ class _ChatScreenState extends State<ChatScreen>
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<ChatScreen>
_scrollToBottom();
} else {
_noteMissedMessage();
unawaited(_holdScrollAfterAppend(anchorId, anchorDy));
unawaited(_holdScrollAfterAppend(anchorId, anchorAt));
}
}
@@ -3010,9 +3056,9 @@ class _ChatScreenState extends State<ChatScreen>
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<ChatScreen>
_scheduleReadMarker();
} else {
_noteMissedMessage();
unawaited(_holdScrollAfterAppend(anchorId, anchorDy));
unawaited(_holdScrollAfterAppend(anchorId, anchorAt));
_reapplyPinIfNeeded();
}
_prank.checkTrigger(message);
@@ -4797,11 +4843,13 @@ class _ChatScreenState extends State<ChatScreen>
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<ChatScreen>
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<ChatScreen>
alignment,
moved ? 0 : attempt + 1,
frames: frames + 1,
epoch: epoch,
onSettled: onSettled,
),
);
@@ -5038,6 +5090,7 @@ class _ChatScreenState extends State<ChatScreen>
alignment,
attempt + 1,
frames: frames + 1,
epoch: epoch,
onSettled: onSettled,
),
);
@@ -5575,9 +5628,14 @@ class _ChatScreenState extends State<ChatScreen>
children: [
Opacity(
opacity: showShimmer ? 0.0 : 1.0,
child: NotificationListener<ScrollEndNotification>(
onNotification: (_) {
_updateReadMarker();
child: NotificationListener<ScrollNotification>(
onNotification: (notification) {
if (notification is ScrollStartNotification &&
notification.dragDetails != null) {
_userGestureEpoch++;
} else if (notification is ScrollEndNotification) {
_updateReadMarker();
}
return false;
},
child: _buildMessagesList(),
@@ -46,24 +46,66 @@ class FormattedMessageText extends StatefulWidget {
List<FormatRange> ranges,
TextStyle style, {
Color? mentionColor,
}) => TextSpan(
style: style,
children: buildInlineChildren(
text,
ranges,
style,
mentionColor: mentionColor,
),
);
static List<InlineSpan> buildInlineChildren(
String text,
List<FormatRange> 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 = <InlineSpan>[];
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
+108
View File
@@ -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<ChatPreviewKind, String> _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<String, ChatPreviewKind> _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<String, dynamic> 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<ChatPreviewThumb> 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<String, dynamic> 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(),
);
}
}
+196
View File
@@ -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<Text>(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<TextSpan> _spans(WidgetTester tester) {
final span = tester.widget<Text>(find.byType(Text).first).textSpan!;
final result = <TextSpan>[];
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);
});
}
+186
View File
@@ -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<String, dynamic> _photo(String preview) => {
'_type': 'PHOTO',
'previewData': preview,
'photoId': 1,
};
Map<String, dynamic> _video(String preview) => {
'_type': 'VIDEO',
'previewData': preview,
'videoId': 2,
};
ChatPreviewMedia _media(Map<String, dynamic> 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);
});
});
}
+233 -8
View File
@@ -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<String> items = [for (var i = 0; i < 40; i++) 'm$i'];
late final Map<String, GlobalKey> keys = {
for (final id in items) id: GlobalKey(),
'newest': GlobalKey(),
};
final List<String> items = [for (var i = 0; i < 200; i++) 'm$i'];
final Map<String, GlobalKey> keys = {};
GlobalKey keyFor(String id) => keys.putIfAbsent(id, GlobalKey.new);
Future<void> 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<int> 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);
});
});
}