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;
}