feat: полное отображение типов сообщений + предпросмотр ссылок и оптимизация фото
Модели (lib/models/):
- attachment.dart — новые типы ShareAttachment (OG-карточка ссылки) и
CallAttachment (звонок); FILE получил вложенное превью и поле token;
LOCATION — поле zoom; единый декодер previewData (сырой WebP → data-URI)
вынесен в decodeAttachPreview
- poll.dart — корректный парсинг голосов ({userId,timestamp}) и признак
своего голоса (options & 1); helper withStateMap
Лента сообщений (message_bubble.dart):
- SHARE — карточка предпросмотра ссылки (картинка, домен, заголовок,
описание), тап открывает URL
- CALL — отображение звонков: аудио/видео/групповые/пропущенные,
длительность, направление; симметричная иконка
- LOCATION — карточка геопозиции, тап открывает карту
- FILE — превью-картинка над строкой файла
- кликабельные ссылки в тексте и подписях (LinkText)
- фикс лишней ширины бабблов гео/share/опроса (IntrinsicWidth + stretch)
- оптимизация рендера фото: убран fade-in, уменьшен размер декода
плиток фото-сетки
Опросы:
- интерактивное голосование через opcode 304 (polls.dart, poll_view.dart):
одиночный/множественный выбор, отметка своего ответа, проценты
Список чатов (chats.dart):
- превью типа вложения для сообщений без текста («Фото», «Опрос: …»,
«Звонок», «Геопозиция», «Файл: …», «Ссылка: …» и т.д.)
Настройки разработчика:
- тумблер «Предпросмотр ссылок» (app_link_preview.dart) — отключает
SHARE-карточки, оставляя текст со ссылкой; применяется на лету
Прочее:
- зависимость url_launcher ^6.3.1 (open_external_url / open_location_on_map)
- bump версии 0.5.0+11 → 0.5.0+12
This commit is contained in:
@@ -63,13 +63,15 @@ class CallsModule {
|
||||
bool isVideo = false,
|
||||
}) async {
|
||||
final conversationId = _uuidV4();
|
||||
// Структура подтверждена дампом основного сокета (opcode 78).
|
||||
final internalParams = jsonEncode({
|
||||
'deviceId': _api.deviceId ?? '',
|
||||
'sdkVersion': '2.8.9',
|
||||
'clientAppKey': _clientAppKey(),
|
||||
'platform': 'ANDROID',
|
||||
'sdkVersion': '0.1.16.4',
|
||||
'clientAppKey': 'CGPGAGLGDIHBABABA',
|
||||
'deviceId': _api.deviceId ?? '',
|
||||
'protocolVersion': 5,
|
||||
'domainId': '',
|
||||
'onlyAdminCanRecord': false,
|
||||
'waitForAdmin': false,
|
||||
'capabilities': '3c03f',
|
||||
});
|
||||
|
||||
@@ -120,12 +122,6 @@ class CallsModule {
|
||||
'-${s.substring(16, 20)}-${s.substring(20)}';
|
||||
}
|
||||
|
||||
static String _clientAppKey() {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
final r = Random();
|
||||
return List.generate(17, (_) => chars[r.nextInt(chars.length)]).join();
|
||||
}
|
||||
|
||||
/// Fetch call history from opcode 79
|
||||
Future<List<CallLogEntry>> fetchHistory(
|
||||
int accountId,
|
||||
|
||||
@@ -181,6 +181,57 @@ class ChatsModule {
|
||||
/// а кеша истории нет — UI должен отрисовать курсивную плашку.
|
||||
static const String lastMsgPlaceholder = '__komet_lastmsg_placeholder__';
|
||||
|
||||
static String? attachPreviewLabel(dynamic attaches) {
|
||||
if (attaches is! List || attaches.isEmpty) return null;
|
||||
final first = attaches.first;
|
||||
if (first is! Map) return null;
|
||||
final type = (first['_type'] as String? ?? '').toUpperCase();
|
||||
switch (type) {
|
||||
case 'PHOTO':
|
||||
return 'Фото';
|
||||
case 'VIDEO':
|
||||
return 'Видео';
|
||||
case 'AUDIO':
|
||||
return 'Голосовое сообщение';
|
||||
case 'FILE':
|
||||
final name = first['name']?.toString();
|
||||
return name != null && name.isNotEmpty ? 'Файл: $name' : 'Файл';
|
||||
case 'STICKER':
|
||||
return 'Стикер';
|
||||
case 'SHARE':
|
||||
final title = first['title']?.toString();
|
||||
return title != null && title.isNotEmpty ? 'Ссылка: $title' : 'Ссылка';
|
||||
case 'POLL':
|
||||
final title = first['title']?.toString();
|
||||
return title != null && title.isNotEmpty ? 'Опрос: $title' : 'Опрос';
|
||||
case 'LOCATION':
|
||||
return 'Геопозиция';
|
||||
case 'CONTACT':
|
||||
return 'Контакт';
|
||||
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 ? 'Видеозвонок' : 'Звонок';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static String? messagePreviewText(Map msg) {
|
||||
final text = msg['text']?.toString();
|
||||
if (text != null && text.isNotEmpty) return text;
|
||||
return attachPreviewLabel(msg['attaches']);
|
||||
}
|
||||
|
||||
static final _messageEventsController =
|
||||
StreamController<MessageEvent>.broadcast();
|
||||
static Stream<MessageEvent> get messageEvents =>
|
||||
@@ -369,7 +420,7 @@ class ChatsModule {
|
||||
newRow['last_event_time'] = msgTime;
|
||||
}
|
||||
}
|
||||
newRow['last_msg_text'] = msgText;
|
||||
newRow['last_msg_text'] = messagePreviewText(msg);
|
||||
if (senderId != null) newRow['last_msg_sender'] = senderId;
|
||||
}
|
||||
if (unread != null) newRow['unread_count'] = unread;
|
||||
@@ -388,8 +439,20 @@ class ChatsModule {
|
||||
final newRow = Map<String, dynamic>.from(chatRow);
|
||||
if (latest.isNotEmpty) {
|
||||
final m = latest.first;
|
||||
String? previewText = m['text']?.toString();
|
||||
if (previewText == null || previewText.isEmpty) {
|
||||
final payloadRaw = m['payload'];
|
||||
if (payloadRaw is String && payloadRaw.isNotEmpty) {
|
||||
try {
|
||||
final payload = jsonDecode(payloadRaw);
|
||||
if (payload is Map) {
|
||||
previewText = attachPreviewLabel(payload['attaches']);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
newRow['last_msg_id'] = int.tryParse(m['id']?.toString() ?? '');
|
||||
newRow['last_msg_text'] = m['text'];
|
||||
newRow['last_msg_text'] = previewText ?? m['text'];
|
||||
newRow['last_msg_time'] = m['time'];
|
||||
newRow['last_msg_sender'] = m['sender_id'];
|
||||
} else {
|
||||
@@ -781,7 +844,7 @@ class ChatsModule {
|
||||
if (lastMsg is Map) {
|
||||
lastMsgId = lastMsg['id'] as int?;
|
||||
lastMsgTime = lastMsg['time'] as int?;
|
||||
lastMsgText = lastMsg['text'] as String?;
|
||||
lastMsgText = messagePreviewText(lastMsg);
|
||||
lastMsgSenderId = lastMsg['sender'] as int?;
|
||||
}
|
||||
|
||||
|
||||
@@ -58,4 +58,34 @@ class PollsModule extends ChangeNotifier {
|
||||
_inFlight.remove(pollId);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> vote(
|
||||
int chatId,
|
||||
String messageId,
|
||||
int pollId,
|
||||
List<int> answersIds,
|
||||
) async {
|
||||
try {
|
||||
final response = await _api.sendRequest(Opcode.sendVote, {
|
||||
'messageId': int.tryParse(messageId) ?? 0,
|
||||
'chatId': chatId,
|
||||
'pollId': pollId,
|
||||
'answersIds': answersIds,
|
||||
});
|
||||
if (!response.isOk) return false;
|
||||
|
||||
final data = response.payload;
|
||||
final state = data is Map ? data['state'] : null;
|
||||
final cached = _cache[pollId];
|
||||
if (state is Map && cached != null) {
|
||||
_cache[pollId] = cached.withStateMap(state);
|
||||
notifyListeners();
|
||||
} else {
|
||||
await fetch(chatId, messageId, pollId, force: true);
|
||||
}
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user