feat/fix: иконки у каналов/групп/ботов, + миниатюры и иконки к видам медиа на экране чатов
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user