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:
klockky
2026-06-11 17:14:36 +03:00
parent 760cdcd877
commit e3635b5d29
14 changed files with 1085 additions and 145 deletions
+6 -10
View File
@@ -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,
+66 -3
View File
@@ -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?;
}
+30
View File
@@ -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;
}
}
}