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:
+135
-68
@@ -1,3 +1,5 @@
|
||||
import 'dart:convert';
|
||||
|
||||
enum AttachmentType {
|
||||
photo,
|
||||
video,
|
||||
@@ -8,6 +10,19 @@ enum AttachmentType {
|
||||
sticker,
|
||||
control,
|
||||
poll,
|
||||
share,
|
||||
call,
|
||||
}
|
||||
|
||||
String? decodeAttachPreview(dynamic raw) {
|
||||
if (raw is String) return raw;
|
||||
if (raw is List) {
|
||||
try {
|
||||
final bytes = List<int>.from(raw);
|
||||
return 'data:image/webp;base64,${base64Encode(bytes)}';
|
||||
} catch (_) {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
abstract class MessageAttachment {
|
||||
@@ -44,8 +59,10 @@ abstract class MessageAttachment {
|
||||
return ControlAttachment.fromMap(map);
|
||||
case 'POLL':
|
||||
return PollAttachment.fromMap(map);
|
||||
case 'CALL':
|
||||
return CallAttachment.fromMap(map);
|
||||
case 'SHARE':
|
||||
return FileAttachment.fromMap(map);
|
||||
return ShareAttachment.fromMap(map);
|
||||
case 'INLINE_KEYBOARD':
|
||||
return UnknownAttachment(map);
|
||||
default:
|
||||
@@ -77,21 +94,9 @@ class PhotoAttachment extends MessageAttachment {
|
||||
}) : super(type: AttachmentType.photo);
|
||||
|
||||
factory PhotoAttachment.fromMap(Map<String, dynamic> map) {
|
||||
String? previewStr;
|
||||
final previewRaw = map['previewData'];
|
||||
if (previewRaw is String) {
|
||||
previewStr = previewRaw;
|
||||
} else if (previewRaw is List) {
|
||||
try {
|
||||
final bytes = List<int>.from(previewRaw);
|
||||
final base64 = String.fromCharCodes(bytes);
|
||||
previewStr = 'data:image/webp;base64,$base64';
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
return PhotoAttachment(
|
||||
previewData: previewStr,
|
||||
baseUrl: map['baseUrl'] as String?,
|
||||
previewData: decodeAttachPreview(map['previewData']),
|
||||
baseUrl: (map['baseUrl'] ?? map['url']) as String?,
|
||||
photoId: map['photoId'] as int?,
|
||||
photoToken: map['photoToken'] as String?,
|
||||
width: map['width'] as int?,
|
||||
@@ -134,20 +139,8 @@ class VideoAttachment extends MessageAttachment {
|
||||
}) : super(type: AttachmentType.video);
|
||||
|
||||
factory VideoAttachment.fromMap(Map<String, dynamic> map) {
|
||||
String? previewStr;
|
||||
final previewRaw = map['previewData'];
|
||||
if (previewRaw is String) {
|
||||
previewStr = previewRaw;
|
||||
} else if (previewRaw is List) {
|
||||
try {
|
||||
final bytes = List<int>.from(previewRaw);
|
||||
final base64 = String.fromCharCodes(bytes);
|
||||
previewStr = 'data:image/webp;base64,$base64';
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
return VideoAttachment(
|
||||
previewData: previewStr,
|
||||
previewData: decodeAttachPreview(map['previewData']),
|
||||
baseUrl: map['baseUrl'] as String?,
|
||||
videoId: map['videoId'] as int?,
|
||||
videoToken: map['videoToken'] as String?,
|
||||
@@ -191,18 +184,6 @@ class AudioAttachment extends MessageAttachment {
|
||||
}) : super(type: AttachmentType.audio);
|
||||
|
||||
factory AudioAttachment.fromMap(Map<String, dynamic> map) {
|
||||
String? previewStr;
|
||||
final previewRaw = map['previewData'];
|
||||
if (previewRaw is String) {
|
||||
previewStr = previewRaw;
|
||||
} else if (previewRaw is List) {
|
||||
try {
|
||||
final bytes = List<int>.from(previewRaw);
|
||||
final base64 = String.fromCharCodes(bytes);
|
||||
previewStr = 'data:image/webp;base64,$base64';
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
String? waveStr;
|
||||
final waveRaw = map['wave'];
|
||||
if (waveRaw is String) {
|
||||
@@ -210,13 +191,12 @@ class AudioAttachment extends MessageAttachment {
|
||||
} else if (waveRaw is List) {
|
||||
try {
|
||||
final bytes = List<int>.from(waveRaw);
|
||||
final base64 = String.fromCharCodes(bytes);
|
||||
waveStr = 'data:image/webp;base64,$base64';
|
||||
waveStr = String.fromCharCodes(bytes);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
return AudioAttachment(
|
||||
previewData: previewStr,
|
||||
previewData: decodeAttachPreview(map['previewData']),
|
||||
baseUrl: map['baseUrl']?.toString(),
|
||||
fileUrl: map['url']?.toString(),
|
||||
audioId: map['audioId'] as int?,
|
||||
@@ -245,6 +225,7 @@ class FileAttachment extends MessageAttachment {
|
||||
final String? fileToken;
|
||||
final String? name;
|
||||
final int? size;
|
||||
final PhotoAttachment? preview;
|
||||
|
||||
const FileAttachment({
|
||||
super.previewData,
|
||||
@@ -254,28 +235,24 @@ class FileAttachment extends MessageAttachment {
|
||||
this.fileToken,
|
||||
this.name,
|
||||
this.size,
|
||||
this.preview,
|
||||
}) : super(type: AttachmentType.file);
|
||||
|
||||
factory FileAttachment.fromMap(Map<String, dynamic> map) {
|
||||
String? previewStr;
|
||||
final previewRaw = map['previewData'];
|
||||
if (previewRaw is String) {
|
||||
previewStr = previewRaw;
|
||||
} else if (previewRaw is List) {
|
||||
try {
|
||||
final bytes = List<int>.from(previewRaw);
|
||||
final base64 = String.fromCharCodes(bytes);
|
||||
previewStr = 'data:image/webp;base64,$base64';
|
||||
} catch (_) {}
|
||||
PhotoAttachment? preview;
|
||||
final previewRaw = map['preview'];
|
||||
if (previewRaw is Map) {
|
||||
preview = PhotoAttachment.fromMap(Map<String, dynamic>.from(previewRaw));
|
||||
}
|
||||
|
||||
return FileAttachment(
|
||||
previewData: previewStr,
|
||||
previewData: decodeAttachPreview(map['previewData']),
|
||||
baseUrl: map['baseUrl'] as String?,
|
||||
fileId: map['fileId'] as int?,
|
||||
fileToken: map['fileToken'] as String?,
|
||||
fileToken: (map['fileToken'] ?? map['token'])?.toString(),
|
||||
name: map['name'] as String?,
|
||||
size: map['size'] as int?,
|
||||
preview: preview,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -288,6 +265,7 @@ class FileAttachment extends MessageAttachment {
|
||||
'fileToken': fileToken,
|
||||
'name': name,
|
||||
'size': size,
|
||||
if (preview != null) 'preview': preview!.toMap(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -308,20 +286,8 @@ class StickerAttachment extends MessageAttachment {
|
||||
}) : super(type: AttachmentType.sticker);
|
||||
|
||||
factory StickerAttachment.fromMap(Map<String, dynamic> map) {
|
||||
String? previewStr;
|
||||
final previewRaw = map['previewData'];
|
||||
if (previewRaw is String) {
|
||||
previewStr = previewRaw;
|
||||
} else if (previewRaw is List) {
|
||||
try {
|
||||
final bytes = List<int>.from(previewRaw);
|
||||
final base64 = String.fromCharCodes(bytes);
|
||||
previewStr = 'data:image/webp;base64,$base64';
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
return StickerAttachment(
|
||||
previewData: previewStr,
|
||||
previewData: decodeAttachPreview(map['previewData']),
|
||||
baseUrl: (map['url'] ?? map['baseUrl'])?.toString(),
|
||||
stickerId: map['stickerId']?.toString(),
|
||||
stickerPackId: map['setId']?.toString() ?? map['stickerPackId']?.toString(),
|
||||
@@ -396,6 +362,7 @@ class ContactAttachment extends MessageAttachment {
|
||||
class LocationAttachment extends MessageAttachment {
|
||||
final double? latitude;
|
||||
final double? longitude;
|
||||
final double? zoom;
|
||||
final String? title;
|
||||
final String? address;
|
||||
|
||||
@@ -405,6 +372,7 @@ class LocationAttachment extends MessageAttachment {
|
||||
super.fileUrl,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
this.zoom,
|
||||
this.title,
|
||||
this.address,
|
||||
}) : super(type: AttachmentType.location);
|
||||
@@ -415,6 +383,7 @@ class LocationAttachment extends MessageAttachment {
|
||||
baseUrl: map['baseUrl'] as String?,
|
||||
latitude: (map['latitude'] as num?)?.toDouble(),
|
||||
longitude: (map['longitude'] as num?)?.toDouble(),
|
||||
zoom: (map['zoom'] as num?)?.toDouble(),
|
||||
title: map['title'] as String?,
|
||||
address: map['address'] as String?,
|
||||
);
|
||||
@@ -427,6 +396,7 @@ class LocationAttachment extends MessageAttachment {
|
||||
'baseUrl': baseUrl,
|
||||
'latitude': latitude,
|
||||
'longitude': longitude,
|
||||
'zoom': zoom,
|
||||
'title': title,
|
||||
'address': address,
|
||||
};
|
||||
@@ -501,6 +471,103 @@ class PollAttachment extends MessageAttachment {
|
||||
};
|
||||
}
|
||||
|
||||
class CallAttachment extends MessageAttachment {
|
||||
final bool isVideo;
|
||||
final int durationMs;
|
||||
final String? hangupType;
|
||||
final String? conversationId;
|
||||
final String? joinLink;
|
||||
final List<int> contactIds;
|
||||
|
||||
const CallAttachment({
|
||||
required this.isVideo,
|
||||
this.durationMs = 0,
|
||||
this.hangupType,
|
||||
this.conversationId,
|
||||
this.joinLink,
|
||||
this.contactIds = const [],
|
||||
}) : super(type: AttachmentType.call);
|
||||
|
||||
bool get isGroup => joinLink != null;
|
||||
|
||||
bool get isMissedOrFailed =>
|
||||
durationMs == 0 ||
|
||||
hangupType == 'CANCELED' ||
|
||||
hangupType == 'REJECTED' ||
|
||||
hangupType == 'MISSED';
|
||||
|
||||
factory CallAttachment.fromMap(Map<String, dynamic> map) {
|
||||
return CallAttachment(
|
||||
isVideo: (map['callType']?.toString().toUpperCase() == 'VIDEO'),
|
||||
durationMs: (map['duration'] as num?)?.toInt() ?? 0,
|
||||
hangupType: map['hangupType']?.toString(),
|
||||
conversationId: map['conversationId']?.toString(),
|
||||
joinLink: map['joinLink']?.toString(),
|
||||
contactIds: (map['contactIds'] as List?)
|
||||
?.map((e) => e is int ? e : int.tryParse(e?.toString() ?? '') ?? 0)
|
||||
.toList() ??
|
||||
const [],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toMap() => {
|
||||
'_type': 'CALL',
|
||||
'callType': isVideo ? 'VIDEO' : 'AUDIO',
|
||||
'duration': durationMs,
|
||||
'hangupType': hangupType,
|
||||
'conversationId': conversationId,
|
||||
if (joinLink != null) 'joinLink': joinLink,
|
||||
if (contactIds.isNotEmpty) 'contactIds': contactIds,
|
||||
};
|
||||
}
|
||||
|
||||
class ShareAttachment extends MessageAttachment {
|
||||
final int? shareId;
|
||||
final String? title;
|
||||
final String? description;
|
||||
final String? url;
|
||||
final String? host;
|
||||
final PhotoAttachment? image;
|
||||
|
||||
const ShareAttachment({
|
||||
this.shareId,
|
||||
this.title,
|
||||
this.description,
|
||||
this.url,
|
||||
this.host,
|
||||
this.image,
|
||||
}) : super(type: AttachmentType.share);
|
||||
|
||||
factory ShareAttachment.fromMap(Map<String, dynamic> map) {
|
||||
PhotoAttachment? image;
|
||||
final imageRaw = map['image'];
|
||||
if (imageRaw is Map) {
|
||||
image = PhotoAttachment.fromMap(Map<String, dynamic>.from(imageRaw));
|
||||
}
|
||||
|
||||
return ShareAttachment(
|
||||
shareId: map['shareId'] as int?,
|
||||
title: map['title']?.toString(),
|
||||
description: map['description']?.toString(),
|
||||
url: map['url']?.toString(),
|
||||
host: map['host']?.toString(),
|
||||
image: image,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toMap() => {
|
||||
'_type': 'SHARE',
|
||||
'shareId': shareId,
|
||||
'title': title,
|
||||
'description': description,
|
||||
'url': url,
|
||||
'host': host,
|
||||
if (image != null) 'image': image!.toMap(),
|
||||
};
|
||||
}
|
||||
|
||||
class ForwardedMessageAttachment extends MessageAttachment {
|
||||
final int originalSenderId;
|
||||
final String? originalSenderName;
|
||||
|
||||
+33
-5
@@ -4,6 +4,7 @@ class PollAnswer {
|
||||
final int voteCount;
|
||||
final double rate;
|
||||
final List<int> votes;
|
||||
final bool mine;
|
||||
|
||||
const PollAnswer({
|
||||
required this.answerId,
|
||||
@@ -11,6 +12,7 @@ class PollAnswer {
|
||||
this.voteCount = 0,
|
||||
this.rate = 0,
|
||||
this.votes = const [],
|
||||
this.mine = false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -35,8 +37,36 @@ class Poll {
|
||||
|
||||
bool get isMultiple => settings & 0x1 != 0;
|
||||
|
||||
bool get hasMyVote => answers.any((a) => a.mine);
|
||||
|
||||
bool votedBy(int userId) =>
|
||||
answers.any((a) => a.votes.contains(userId));
|
||||
answers.any((a) => a.mine || a.votes.contains(userId));
|
||||
|
||||
static List<int> _parseVoterIds(dynamic votes) {
|
||||
if (votes is! List) return const [];
|
||||
final ids = <int>[];
|
||||
for (final v in votes) {
|
||||
if (v is int) {
|
||||
ids.add(v);
|
||||
} else if (v is Map && v['userId'] is int) {
|
||||
ids.add(v['userId'] as int);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
Poll withStateMap(Map<dynamic, dynamic> stateMap) {
|
||||
return Poll.fromServerMap({
|
||||
'pollId': pollId,
|
||||
'title': title,
|
||||
'settings': settings,
|
||||
'version': version,
|
||||
'answers': [
|
||||
for (final a in answers) {'answerId': a.answerId, 'text': a.text},
|
||||
],
|
||||
'state': stateMap,
|
||||
});
|
||||
}
|
||||
|
||||
factory Poll.fromServerMap(Map<dynamic, dynamic> map) {
|
||||
final state = map['state'];
|
||||
@@ -64,10 +94,8 @@ class Poll {
|
||||
text: a['text']?.toString() ?? '',
|
||||
voteCount: (res?['voteCount'] as num?)?.toInt() ?? 0,
|
||||
rate: (res?['rate'] as num?)?.toDouble() ?? 0,
|
||||
votes: (res?['votes'] as List?)
|
||||
?.whereType<int>()
|
||||
.toList() ??
|
||||
const [],
|
||||
votes: _parseVoterIds(res?['votes']),
|
||||
mine: ((res?['options'] as num?)?.toInt() ?? 0) & 0x1 != 0,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user