Import IKAR messenger client and server
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
enum AdminChatKind { channel, group }
|
||||
|
||||
enum AdminRight {
|
||||
editInfo(8),
|
||||
createPosts(32 | 256),
|
||||
editPosts(512),
|
||||
deletePosts(1024),
|
||||
deleteMessages(1),
|
||||
pinMessages(16),
|
||||
manageFollowers(2 | 128),
|
||||
manageMembers(2),
|
||||
editLink(128),
|
||||
viewStats(2048),
|
||||
manageAdmins(4);
|
||||
|
||||
const AdminRight(this.mask);
|
||||
|
||||
final int mask;
|
||||
}
|
||||
|
||||
class AdminRights {
|
||||
static const int ownerBits = 4095;
|
||||
static const int _groupBaseBits = 32 | 64;
|
||||
|
||||
final int bits;
|
||||
|
||||
const AdminRights(this.bits);
|
||||
|
||||
static List<List<AdminRight>> layoutOf(AdminChatKind kind) => switch (kind) {
|
||||
AdminChatKind.channel => const [
|
||||
[AdminRight.editInfo],
|
||||
[
|
||||
AdminRight.createPosts,
|
||||
AdminRight.editPosts,
|
||||
AdminRight.deletePosts,
|
||||
AdminRight.pinMessages,
|
||||
],
|
||||
[AdminRight.manageFollowers],
|
||||
[AdminRight.viewStats],
|
||||
[AdminRight.manageAdmins],
|
||||
],
|
||||
AdminChatKind.group => const [
|
||||
[AdminRight.editInfo],
|
||||
[AdminRight.deleteMessages, AdminRight.pinMessages],
|
||||
[AdminRight.manageMembers, AdminRight.editLink],
|
||||
[AdminRight.manageAdmins],
|
||||
],
|
||||
};
|
||||
|
||||
static AdminRights appointDefaults(AdminChatKind kind) => switch (kind) {
|
||||
AdminChatKind.channel => _union(layoutOf(kind).expand((group) => group), 0),
|
||||
AdminChatKind.group => _union(const [
|
||||
AdminRight.editInfo,
|
||||
AdminRight.deleteMessages,
|
||||
AdminRight.pinMessages,
|
||||
AdminRight.manageMembers,
|
||||
], _groupBaseBits),
|
||||
};
|
||||
|
||||
static AdminRights _union(Iterable<AdminRight> rights, int base) =>
|
||||
AdminRights(rights.fold(base, (bits, right) => bits | right.mask));
|
||||
|
||||
bool has(AdminRight right) => bits & right.mask == right.mask;
|
||||
|
||||
AdminRights withRight(AdminRight right, bool enabled) =>
|
||||
AdminRights(enabled ? bits | right.mask : bits & ~right.mask);
|
||||
|
||||
bool isEmptyFor(AdminChatKind kind) => layoutOf(
|
||||
kind,
|
||||
).expand((group) => group).every((right) => bits & right.mask == 0);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => other is AdminRights && other.bits == bits;
|
||||
|
||||
@override
|
||||
int get hashCode => bits.hashCode;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
class Animoji {
|
||||
final int id;
|
||||
final String emoji;
|
||||
final int setId;
|
||||
final String? iconUrl;
|
||||
final String? lottieUrl;
|
||||
final String? lottiePlayUrl;
|
||||
|
||||
const Animoji({
|
||||
required this.id,
|
||||
required this.emoji,
|
||||
this.setId = 0,
|
||||
this.iconUrl,
|
||||
this.lottieUrl,
|
||||
this.lottiePlayUrl,
|
||||
});
|
||||
|
||||
static Animoji? fromMap(Map<dynamic, dynamic> map) {
|
||||
final id = map['id'];
|
||||
final emoji = map['emoji']?.toString();
|
||||
if (id is! int || emoji == null || emoji.isEmpty) return null;
|
||||
return Animoji(
|
||||
id: id,
|
||||
emoji: emoji,
|
||||
setId: map['setId'] is int ? map['setId'] as int : 0,
|
||||
iconUrl: map['iconUrl']?.toString(),
|
||||
lottieUrl: map['lottieUrl']?.toString(),
|
||||
lottiePlayUrl: map['lottiePlayUrl']?.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,822 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import '../core/utils/parse.dart';
|
||||
import '../core/utils/text_format.dart';
|
||||
|
||||
enum AttachmentType {
|
||||
photo,
|
||||
video,
|
||||
audio,
|
||||
file,
|
||||
contact,
|
||||
location,
|
||||
sticker,
|
||||
control,
|
||||
poll,
|
||||
share,
|
||||
call,
|
||||
inlineKeyboard,
|
||||
forward,
|
||||
unknown,
|
||||
}
|
||||
|
||||
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 {
|
||||
final AttachmentType type;
|
||||
final String? previewData;
|
||||
final String? baseUrl;
|
||||
final String? fileUrl;
|
||||
|
||||
const MessageAttachment({
|
||||
required this.type,
|
||||
this.previewData,
|
||||
this.baseUrl,
|
||||
this.fileUrl,
|
||||
});
|
||||
|
||||
factory MessageAttachment.fromMap(Map<String, dynamic> map) {
|
||||
final type = (map['_type'] as String? ?? '').toUpperCase();
|
||||
switch (type) {
|
||||
case 'PHOTO':
|
||||
return PhotoAttachment.fromMap(map);
|
||||
case 'VIDEO':
|
||||
return VideoAttachment.fromMap(map);
|
||||
case 'AUDIO':
|
||||
return AudioAttachment.fromMap(map);
|
||||
case 'FILE':
|
||||
return FileAttachment.fromMap(map);
|
||||
case 'STICKER':
|
||||
return StickerAttachment.fromMap(map);
|
||||
case 'CONTACT':
|
||||
return ContactAttachment.fromMap(map);
|
||||
case 'LOCATION':
|
||||
return LocationAttachment.fromMap(map);
|
||||
case 'CONTROL':
|
||||
return ControlAttachment.fromMap(map);
|
||||
case 'POLL':
|
||||
return PollAttachment.fromMap(map);
|
||||
case 'CALL':
|
||||
return CallAttachment.fromMap(map);
|
||||
case 'SHARE':
|
||||
return ShareAttachment.fromMap(map);
|
||||
case 'INLINE_KEYBOARD':
|
||||
return InlineKeyboardAttachment.fromMap(map);
|
||||
default:
|
||||
return UnknownAttachment(map);
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap();
|
||||
}
|
||||
|
||||
class PhotoAttachment extends MessageAttachment {
|
||||
final int? photoId;
|
||||
final String? photoToken;
|
||||
final int? width;
|
||||
final int? height;
|
||||
final int? size;
|
||||
final String? localPath;
|
||||
|
||||
const PhotoAttachment({
|
||||
super.previewData,
|
||||
super.baseUrl,
|
||||
super.fileUrl,
|
||||
this.photoId,
|
||||
this.photoToken,
|
||||
this.width,
|
||||
this.height,
|
||||
this.size,
|
||||
this.localPath,
|
||||
}) : super(type: AttachmentType.photo);
|
||||
|
||||
factory PhotoAttachment.fromMap(Map<String, dynamic> map) {
|
||||
return PhotoAttachment(
|
||||
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?,
|
||||
height: map['height'] as int?,
|
||||
size: map['size'] as int?,
|
||||
localPath: map['localPath'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toMap() => {
|
||||
'_type': 'PHOTO',
|
||||
'previewData': previewData,
|
||||
'baseUrl': baseUrl,
|
||||
'photoId': photoId,
|
||||
'photoToken': photoToken,
|
||||
'width': width,
|
||||
'height': height,
|
||||
'size': size,
|
||||
'localPath': localPath,
|
||||
};
|
||||
}
|
||||
|
||||
class VideoAttachment extends MessageAttachment {
|
||||
final int? videoId;
|
||||
final String? videoToken;
|
||||
final String? thumbnail;
|
||||
final int? width;
|
||||
final int? height;
|
||||
final int? duration;
|
||||
final int? size;
|
||||
final String? localPath;
|
||||
|
||||
final int? videoType;
|
||||
|
||||
bool get isNote => videoType == 1;
|
||||
|
||||
const VideoAttachment({
|
||||
super.previewData,
|
||||
super.baseUrl,
|
||||
super.fileUrl,
|
||||
this.videoId,
|
||||
this.videoToken,
|
||||
this.thumbnail,
|
||||
this.width,
|
||||
this.height,
|
||||
this.duration,
|
||||
this.size,
|
||||
this.videoType,
|
||||
this.localPath,
|
||||
}) : super(type: AttachmentType.video);
|
||||
|
||||
factory VideoAttachment.fromMap(Map<String, dynamic> map) {
|
||||
return VideoAttachment(
|
||||
previewData: decodeAttachPreview(map['previewData']),
|
||||
baseUrl: map['baseUrl'] as String?,
|
||||
videoId: map['videoId'] as int?,
|
||||
videoToken: (map['token'] ?? map['videoToken'])?.toString(),
|
||||
thumbnail: map['thumbnail'] as String?,
|
||||
width: map['width'] as int?,
|
||||
height: map['height'] as int?,
|
||||
duration: map['duration'] as int?,
|
||||
size: map['size'] as int?,
|
||||
videoType: map['videoType'] as int?,
|
||||
localPath: map['localPath'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toMap() => {
|
||||
'_type': 'VIDEO',
|
||||
'previewData': previewData,
|
||||
'baseUrl': baseUrl,
|
||||
'videoId': videoId,
|
||||
'videoToken': videoToken,
|
||||
'thumbnail': thumbnail,
|
||||
'width': width,
|
||||
'height': height,
|
||||
'duration': duration,
|
||||
'size': size,
|
||||
'videoType': videoType,
|
||||
'localPath': localPath,
|
||||
};
|
||||
}
|
||||
|
||||
class AudioAttachment extends MessageAttachment {
|
||||
final int? audioId;
|
||||
final String? audioToken;
|
||||
final int? duration;
|
||||
final int? size;
|
||||
final String? waveform;
|
||||
|
||||
const AudioAttachment({
|
||||
super.previewData,
|
||||
super.baseUrl,
|
||||
super.fileUrl,
|
||||
this.audioId,
|
||||
this.audioToken,
|
||||
this.duration,
|
||||
this.size,
|
||||
this.waveform,
|
||||
}) : super(type: AttachmentType.audio);
|
||||
|
||||
factory AudioAttachment.fromMap(Map<String, dynamic> map) {
|
||||
String? waveStr;
|
||||
final waveRaw = map['wave'];
|
||||
if (waveRaw is String) {
|
||||
waveStr = waveRaw;
|
||||
} else if (waveRaw is List) {
|
||||
try {
|
||||
final bytes = List<int>.from(waveRaw);
|
||||
waveStr = String.fromCharCodes(bytes);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
return AudioAttachment(
|
||||
previewData: decodeAttachPreview(map['previewData']),
|
||||
baseUrl: map['baseUrl']?.toString(),
|
||||
fileUrl: map['url']?.toString(),
|
||||
audioId: map['audioId'] as int?,
|
||||
audioToken: map['token']?.toString(),
|
||||
duration: map['duration'] as int?,
|
||||
size: map['size'] as int?,
|
||||
waveform: waveStr,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toMap() => {
|
||||
'_type': 'AUDIO',
|
||||
'previewData': previewData,
|
||||
'baseUrl': baseUrl,
|
||||
'audioId': audioId,
|
||||
'audioToken': audioToken,
|
||||
'duration': duration,
|
||||
'size': size,
|
||||
'waveform': waveform,
|
||||
};
|
||||
}
|
||||
|
||||
class FileAttachment extends MessageAttachment {
|
||||
final int? fileId;
|
||||
final String? fileToken;
|
||||
final String? name;
|
||||
final int? size;
|
||||
final PhotoAttachment? preview;
|
||||
|
||||
const FileAttachment({
|
||||
super.previewData,
|
||||
super.baseUrl,
|
||||
super.fileUrl,
|
||||
this.fileId,
|
||||
this.fileToken,
|
||||
this.name,
|
||||
this.size,
|
||||
this.preview,
|
||||
}) : super(type: AttachmentType.file);
|
||||
|
||||
factory FileAttachment.fromMap(Map<String, dynamic> map) {
|
||||
PhotoAttachment? preview;
|
||||
final previewRaw = map['preview'];
|
||||
if (previewRaw is Map) {
|
||||
preview = PhotoAttachment.fromMap(Map<String, dynamic>.from(previewRaw));
|
||||
}
|
||||
|
||||
return FileAttachment(
|
||||
previewData: decodeAttachPreview(map['previewData']),
|
||||
baseUrl: map['baseUrl'] as String?,
|
||||
fileId: map['fileId'] as int?,
|
||||
fileToken: (map['fileToken'] ?? map['token'])?.toString(),
|
||||
name: map['name'] as String?,
|
||||
size: map['size'] as int?,
|
||||
preview: preview,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toMap() => {
|
||||
'_type': 'FILE',
|
||||
'previewData': previewData,
|
||||
'baseUrl': baseUrl,
|
||||
'fileId': fileId,
|
||||
'fileToken': fileToken,
|
||||
'name': name,
|
||||
'size': size,
|
||||
if (preview != null) 'preview': preview!.toMap(),
|
||||
};
|
||||
}
|
||||
|
||||
class StickerAttachment extends MessageAttachment {
|
||||
final String? stickerId;
|
||||
final String? stickerPackId;
|
||||
final String? lottieUrl;
|
||||
final int? width;
|
||||
final int? height;
|
||||
|
||||
const StickerAttachment({
|
||||
super.previewData,
|
||||
super.baseUrl,
|
||||
super.fileUrl,
|
||||
this.stickerId,
|
||||
this.stickerPackId,
|
||||
this.lottieUrl,
|
||||
this.width,
|
||||
this.height,
|
||||
}) : super(type: AttachmentType.sticker);
|
||||
|
||||
bool get isAnimated => lottieUrl != null && lottieUrl!.isNotEmpty;
|
||||
|
||||
factory StickerAttachment.fromMap(Map<String, dynamic> map) {
|
||||
return StickerAttachment(
|
||||
previewData: decodeAttachPreview(map['previewData']),
|
||||
baseUrl: (map['url'] ?? map['baseUrl'])?.toString(),
|
||||
stickerId: map['stickerId']?.toString(),
|
||||
stickerPackId:
|
||||
map['setId']?.toString() ?? map['stickerPackId']?.toString(),
|
||||
lottieUrl: map['lottieUrl']?.toString(),
|
||||
width: map['width'] as int?,
|
||||
height: map['height'] as int?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toMap() => {
|
||||
'_type': 'STICKER',
|
||||
'previewData': previewData,
|
||||
'baseUrl': baseUrl,
|
||||
'stickerId': stickerId,
|
||||
'stickerPackId': stickerPackId,
|
||||
'lottieUrl': lottieUrl,
|
||||
'width': width,
|
||||
'height': height,
|
||||
};
|
||||
}
|
||||
|
||||
class ContactAttachment extends MessageAttachment {
|
||||
final String? userId;
|
||||
final String? firstName;
|
||||
final String? lastName;
|
||||
final String? phoneNumber;
|
||||
final String? photoUrl;
|
||||
final int? contactId;
|
||||
final String? name;
|
||||
|
||||
const ContactAttachment({
|
||||
super.previewData,
|
||||
super.baseUrl,
|
||||
super.fileUrl,
|
||||
this.userId,
|
||||
this.firstName,
|
||||
this.lastName,
|
||||
this.phoneNumber,
|
||||
this.photoUrl,
|
||||
this.contactId,
|
||||
this.name,
|
||||
}) : super(type: AttachmentType.contact);
|
||||
|
||||
factory ContactAttachment.fromMap(Map<String, dynamic> map) {
|
||||
return ContactAttachment(
|
||||
previewData: map['previewData']?.toString(),
|
||||
baseUrl: map['baseUrl']?.toString(),
|
||||
userId: map['userId']?.toString(),
|
||||
firstName: map['firstName']?.toString(),
|
||||
lastName: map['lastName']?.toString(),
|
||||
phoneNumber: map['phoneNumber']?.toString(),
|
||||
photoUrl: map['photoUrl']?.toString(),
|
||||
contactId: map['contactId'] is int
|
||||
? map['contactId'] as int
|
||||
: int.tryParse(map['contactId']?.toString() ?? ''),
|
||||
name: map['name']?.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toMap() => {
|
||||
'_type': 'CONTACT',
|
||||
'previewData': previewData,
|
||||
'baseUrl': baseUrl,
|
||||
'userId': userId,
|
||||
'firstName': firstName,
|
||||
'lastName': lastName,
|
||||
'phoneNumber': phoneNumber,
|
||||
'photoUrl': photoUrl,
|
||||
'contactId': contactId,
|
||||
'name': name,
|
||||
};
|
||||
}
|
||||
|
||||
class LocationAttachment extends MessageAttachment {
|
||||
final double? latitude;
|
||||
final double? longitude;
|
||||
final double? zoom;
|
||||
final String? title;
|
||||
final String? address;
|
||||
|
||||
const LocationAttachment({
|
||||
super.previewData,
|
||||
super.baseUrl,
|
||||
super.fileUrl,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
this.zoom,
|
||||
this.title,
|
||||
this.address,
|
||||
}) : super(type: AttachmentType.location);
|
||||
|
||||
factory LocationAttachment.fromMap(Map<String, dynamic> map) {
|
||||
return LocationAttachment(
|
||||
previewData: map['previewData'] as String?,
|
||||
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?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toMap() => {
|
||||
'_type': 'LOCATION',
|
||||
'previewData': previewData,
|
||||
'baseUrl': baseUrl,
|
||||
'latitude': latitude,
|
||||
'longitude': longitude,
|
||||
'zoom': zoom,
|
||||
'title': title,
|
||||
'address': address,
|
||||
};
|
||||
}
|
||||
|
||||
class ControlAttachment extends MessageAttachment {
|
||||
static const String botStartedEvent = 'botStarted';
|
||||
|
||||
final String? event;
|
||||
final String? title;
|
||||
final List<int>? userIds;
|
||||
final int? userId;
|
||||
final String? startPayload;
|
||||
final String? imageUrl;
|
||||
final String? fullImageUrl;
|
||||
|
||||
const ControlAttachment({
|
||||
super.previewData,
|
||||
super.baseUrl,
|
||||
super.fileUrl,
|
||||
this.event,
|
||||
this.title,
|
||||
this.userIds,
|
||||
this.userId,
|
||||
this.startPayload,
|
||||
this.imageUrl,
|
||||
this.fullImageUrl,
|
||||
}) : super(type: AttachmentType.control);
|
||||
|
||||
bool get isBotStart => event == botStartedEvent;
|
||||
|
||||
factory ControlAttachment.fromMap(Map<String, dynamic> map) {
|
||||
String? title = map['title']?.toString();
|
||||
if ((title == null || title.isEmpty) && map['shortMessage'] != null) {
|
||||
title = map['shortMessage'].toString();
|
||||
}
|
||||
|
||||
return ControlAttachment(
|
||||
previewData: map['previewData']?.toString(),
|
||||
baseUrl: map['baseUrl']?.toString(),
|
||||
event: map['event']?.toString(),
|
||||
title: title,
|
||||
userIds: map['userIds'] is List ? parseIntList(map['userIds']) : null,
|
||||
userId: map['userId'] is int
|
||||
? map['userId'] as int
|
||||
: int.tryParse(map['userId']?.toString() ?? ''),
|
||||
startPayload: map['startPayload']?.toString(),
|
||||
imageUrl: map['url']?.toString(),
|
||||
fullImageUrl: map['fullUrl']?.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toMap() => {
|
||||
'_type': 'CONTROL',
|
||||
'previewData': previewData,
|
||||
'baseUrl': baseUrl,
|
||||
'event': event,
|
||||
'title': title,
|
||||
'userIds': userIds,
|
||||
'userId': userId,
|
||||
'startPayload': startPayload,
|
||||
'url': imageUrl,
|
||||
'fullUrl': fullImageUrl,
|
||||
};
|
||||
}
|
||||
|
||||
class PollAttachment extends MessageAttachment {
|
||||
final int pollId;
|
||||
final String? title;
|
||||
|
||||
const PollAttachment({required this.pollId, this.title})
|
||||
: super(type: AttachmentType.poll);
|
||||
|
||||
factory PollAttachment.fromMap(Map<String, dynamic> map) {
|
||||
final id = map['pollId'] ?? map['id'];
|
||||
return PollAttachment(
|
||||
pollId: id is int ? id : int.tryParse(id?.toString() ?? '') ?? 0,
|
||||
title: (map['title'] ?? map['question'])?.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toMap() => {
|
||||
'_type': 'POLL',
|
||||
'pollId': pollId,
|
||||
'title': title,
|
||||
};
|
||||
}
|
||||
|
||||
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: parseIntList(map['contactIds']),
|
||||
);
|
||||
}
|
||||
|
||||
@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 InlineKeyboardButton {
|
||||
final String type;
|
||||
final String text;
|
||||
final String? url;
|
||||
final String? webApp;
|
||||
final int? contactId;
|
||||
final String? payload;
|
||||
|
||||
const InlineKeyboardButton({
|
||||
required this.type,
|
||||
required this.text,
|
||||
this.url,
|
||||
this.webApp,
|
||||
this.contactId,
|
||||
this.payload,
|
||||
});
|
||||
|
||||
factory InlineKeyboardButton.fromMap(Map<String, dynamic> map) {
|
||||
return InlineKeyboardButton(
|
||||
type: (map['type'] as String? ?? '').toUpperCase(),
|
||||
text: map['text']?.toString() ?? '',
|
||||
url: map['url']?.toString(),
|
||||
webApp: map['webApp']?.toString(),
|
||||
contactId: map['contactId'] is int
|
||||
? map['contactId'] as int
|
||||
: int.tryParse(map['contactId']?.toString() ?? ''),
|
||||
payload: map['payload']?.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() => {
|
||||
'type': type,
|
||||
'text': text,
|
||||
if (url != null) 'url': url,
|
||||
if (webApp != null) 'webApp': webApp,
|
||||
if (contactId != null) 'contactId': contactId,
|
||||
if (payload != null) 'payload': payload,
|
||||
};
|
||||
}
|
||||
|
||||
class InlineKeyboardAttachment extends MessageAttachment {
|
||||
final String? callbackId;
|
||||
final List<List<InlineKeyboardButton>> rows;
|
||||
|
||||
const InlineKeyboardAttachment({this.callbackId, required this.rows})
|
||||
: super(type: AttachmentType.inlineKeyboard);
|
||||
|
||||
bool get isEmpty => rows.every((row) => row.isEmpty);
|
||||
|
||||
factory InlineKeyboardAttachment.fromMap(Map<String, dynamic> map) {
|
||||
final keyboard = map['keyboard'];
|
||||
final rawRows = keyboard is Map ? keyboard['buttons'] as List? : null;
|
||||
final rows = <List<InlineKeyboardButton>>[];
|
||||
if (rawRows != null) {
|
||||
for (final row in rawRows) {
|
||||
if (row is! List) continue;
|
||||
rows.add(
|
||||
row
|
||||
.whereType<Map>()
|
||||
.map(
|
||||
(b) =>
|
||||
InlineKeyboardButton.fromMap(Map<String, dynamic>.from(b)),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
return InlineKeyboardAttachment(
|
||||
callbackId: map['callbackId']?.toString(),
|
||||
rows: rows,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toMap() => {
|
||||
'_type': 'INLINE_KEYBOARD',
|
||||
if (callbackId != null) 'callbackId': callbackId,
|
||||
'keyboard': {
|
||||
'buttons': rows.map((row) => row.map((b) => b.toMap()).toList()).toList(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
class ForwardedMessageAttachment extends MessageAttachment {
|
||||
final int originalSenderId;
|
||||
final String? originalSenderName;
|
||||
final String? originalSenderAvatar;
|
||||
final String? originalType;
|
||||
final String? originalMessageId;
|
||||
final int? originalTime;
|
||||
final String? originalText;
|
||||
final int? originalChatId;
|
||||
final String? originalChatAccess;
|
||||
final List<FormatRange> originalFormatRanges;
|
||||
final List<MessageAttachment>? originalAttachments;
|
||||
final ContactAttachment? originalContact;
|
||||
|
||||
const ForwardedMessageAttachment({
|
||||
required this.originalSenderId,
|
||||
this.originalSenderName,
|
||||
this.originalSenderAvatar,
|
||||
this.originalType,
|
||||
this.originalMessageId,
|
||||
this.originalTime,
|
||||
this.originalText,
|
||||
this.originalChatId,
|
||||
this.originalChatAccess,
|
||||
this.originalFormatRanges = const [],
|
||||
this.originalAttachments,
|
||||
this.originalContact,
|
||||
}) : super(type: AttachmentType.forward);
|
||||
|
||||
bool get isChannel => originalType == 'CHANNEL';
|
||||
|
||||
bool get isFromPrivateChat => originalChatAccess == 'PRIVATE';
|
||||
|
||||
factory ForwardedMessageAttachment.fromMap(Map<String, dynamic> map) {
|
||||
final linkRaw = map['link'];
|
||||
Map<String, dynamic>? link;
|
||||
if (linkRaw is Map) {
|
||||
link = Map<String, dynamic>.from(linkRaw);
|
||||
}
|
||||
|
||||
Map<String, dynamic>? message;
|
||||
if (link != null) {
|
||||
final msgRaw = link['message'];
|
||||
if (msgRaw is Map) {
|
||||
message = Map<String, dynamic>.from(msgRaw);
|
||||
}
|
||||
}
|
||||
|
||||
List<MessageAttachment>? originalAttaches;
|
||||
ContactAttachment? originalContact;
|
||||
if (message != null) {
|
||||
final attaches = message['attaches'] as List?;
|
||||
if (attaches != null) {
|
||||
final contactAttaches = attaches
|
||||
.whereType<Map>()
|
||||
.where((a) => (a['_type'] as String?)?.toUpperCase() == 'CONTACT')
|
||||
.toList();
|
||||
if (contactAttaches.isNotEmpty) {
|
||||
originalContact = ContactAttachment.fromMap(
|
||||
Map<String, dynamic>.from(contactAttaches.first),
|
||||
);
|
||||
}
|
||||
originalAttaches = attaches
|
||||
.whereType<Map>()
|
||||
.where((a) {
|
||||
final type = (a['_type'] as String?)?.toUpperCase();
|
||||
return type != 'CONTACT';
|
||||
})
|
||||
.map((a) => MessageAttachment.fromMap(Map<String, dynamic>.from(a)))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
final originalType = message?['type']?.toString().toUpperCase();
|
||||
final isChannel = originalType == 'CHANNEL';
|
||||
final channelName = link?['chatName']?.toString().trim();
|
||||
final channelAvatar = link?['chatIconUrl']?.toString().trim();
|
||||
|
||||
return ForwardedMessageAttachment(
|
||||
originalSenderId: parseIntOrNull(message?['sender']) ?? 0,
|
||||
originalSenderName:
|
||||
isChannel && channelName != null && channelName.isNotEmpty
|
||||
? channelName
|
||||
: null,
|
||||
originalSenderAvatar:
|
||||
isChannel && channelAvatar != null && channelAvatar.isNotEmpty
|
||||
? channelAvatar
|
||||
: null,
|
||||
originalType: originalType,
|
||||
originalMessageId: message?['id']?.toString(),
|
||||
originalTime: parseIntOrNull(message?['time']),
|
||||
originalText: message?['text']?.toString(),
|
||||
originalChatId: parseIntOrNull(link?['chatId']),
|
||||
originalChatAccess: link?['chatAccessType']?.toString().toUpperCase(),
|
||||
originalFormatRanges: parseFormatElements(message?['elements']),
|
||||
originalAttachments: originalAttaches,
|
||||
originalContact: originalContact,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toMap() => {
|
||||
'_type': 'FORWARD',
|
||||
'originalSenderId': originalSenderId,
|
||||
'originalSenderName': originalSenderName,
|
||||
'originalSenderAvatar': originalSenderAvatar,
|
||||
'originalType': originalType,
|
||||
'originalMessageId': originalMessageId,
|
||||
'originalTime': originalTime,
|
||||
'originalText': originalText,
|
||||
'originalChatId': originalChatId,
|
||||
'originalChatAccess': originalChatAccess,
|
||||
'originalElements': serializeFormatElements(originalFormatRanges),
|
||||
};
|
||||
}
|
||||
|
||||
class UnknownAttachment extends MessageAttachment {
|
||||
final Map<String, dynamic> rawData;
|
||||
|
||||
const UnknownAttachment(this.rawData) : super(type: AttachmentType.unknown);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toMap() => rawData;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import '../core/utils/text_format.dart';
|
||||
import 'contact_info.dart';
|
||||
|
||||
class BotCommand {
|
||||
final String name;
|
||||
final String? description;
|
||||
|
||||
const BotCommand({required this.name, this.description});
|
||||
|
||||
factory BotCommand.fromMap(Map map) => BotCommand(
|
||||
name: map['name']?.toString() ?? '',
|
||||
description: (map['description'] as String?)?.trim().isNotEmpty == true
|
||||
? (map['description'] as String).trim()
|
||||
: null,
|
||||
);
|
||||
|
||||
String get slash => '/$name';
|
||||
}
|
||||
|
||||
// #***! данные бота плюс карточка контакта
|
||||
class BotStartMessage {
|
||||
final String text;
|
||||
final List<FormatRange> ranges;
|
||||
|
||||
const BotStartMessage({required this.text, this.ranges = const []});
|
||||
|
||||
static BotStartMessage? fromPayload(Object? raw) {
|
||||
if (raw is! Map) return null;
|
||||
final body = raw['text'];
|
||||
if (body is! Map) return null;
|
||||
final text = body['text'];
|
||||
if (text is! String || text.trim().isEmpty) return null;
|
||||
return BotStartMessage(
|
||||
text: text,
|
||||
ranges: parseFormatElements(body['elements']),
|
||||
);
|
||||
}
|
||||
|
||||
({String heading, String body, List<FormatRange> bodyRanges}) get sections {
|
||||
var split = 0;
|
||||
for (final range in ranges) {
|
||||
if (range.format == TextFormat.heading && range.start == 0) {
|
||||
split = range.end.clamp(0, text.length);
|
||||
break;
|
||||
}
|
||||
}
|
||||
final rest = text.substring(split);
|
||||
final bodyStart = split + rest.length - rest.trimLeft().length;
|
||||
final body = text.substring(bodyStart).trimRight();
|
||||
return (
|
||||
heading: text.substring(0, split).trim(),
|
||||
body: body,
|
||||
bodyRanges: [
|
||||
for (final range in ranges)
|
||||
if (range.format != TextFormat.heading &&
|
||||
range.start >= bodyStart &&
|
||||
range.start - bodyStart < body.length)
|
||||
FormatRange(
|
||||
format: range.format,
|
||||
start: range.start - bodyStart,
|
||||
length: range.end - bodyStart > body.length
|
||||
? body.length - (range.start - bodyStart)
|
||||
: range.length,
|
||||
entityId: range.entityId,
|
||||
entityName: range.entityName,
|
||||
attributes: range.attributes,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class BotInfo {
|
||||
final int botId;
|
||||
final List<BotCommand> commands;
|
||||
final ContactInfo? contact;
|
||||
final BotStartMessage? startMessage;
|
||||
|
||||
const BotInfo({
|
||||
required this.botId,
|
||||
required this.commands,
|
||||
this.contact,
|
||||
this.startMessage,
|
||||
});
|
||||
|
||||
factory BotInfo.fromPayload(int botId, Map<String, dynamic> payload) {
|
||||
final rawCommands = payload['commands'];
|
||||
final commands = <BotCommand>[];
|
||||
if (rawCommands is List) {
|
||||
for (final c in rawCommands.whereType<Map>()) {
|
||||
final command = BotCommand.fromMap(c);
|
||||
if (command.name.isNotEmpty) commands.add(command);
|
||||
}
|
||||
}
|
||||
final rawContact = payload['contact'];
|
||||
return BotInfo(
|
||||
botId: botId,
|
||||
commands: commands,
|
||||
contact: rawContact is Map
|
||||
? ContactInfo.fromMap(Map<String, dynamic>.from(rawContact))
|
||||
: null,
|
||||
startMessage: BotStartMessage.fromPayload(payload['startMessage']),
|
||||
);
|
||||
}
|
||||
|
||||
String? get description => contact?.raw['description'] as String?;
|
||||
|
||||
String? get link => contact?.raw['link'] as String?;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import '../core/utils/parse.dart';
|
||||
|
||||
class ChatCall {
|
||||
final String joinLink;
|
||||
final bool isVideo;
|
||||
final int participantsCount;
|
||||
final List<int> participantIds;
|
||||
|
||||
const ChatCall({
|
||||
required this.joinLink,
|
||||
required this.isVideo,
|
||||
required this.participantsCount,
|
||||
this.participantIds = const [],
|
||||
});
|
||||
|
||||
static ChatCall? fromServer(Object? raw) {
|
||||
if (raw is! Map) return null;
|
||||
final joinLink = raw['joinLink']?.toString() ?? '';
|
||||
if (joinLink.isEmpty) return null;
|
||||
final participantIds = parseIntList(raw['previewParticipantIds']);
|
||||
final approxCount = parseIntOrNull(raw['approxParticipantsCount']) ?? 0;
|
||||
final participantsCount = approxCount > 0
|
||||
? approxCount
|
||||
: participantIds.length;
|
||||
if (participantsCount <= 0) return null;
|
||||
return ChatCall(
|
||||
joinLink: joinLink,
|
||||
isVideo: raw['callType']?.toString().toUpperCase() == 'VIDEO',
|
||||
participantsCount: participantsCount,
|
||||
participantIds: participantIds,
|
||||
);
|
||||
}
|
||||
|
||||
String encode() => jsonEncode({
|
||||
'l': joinLink,
|
||||
if (isVideo) 'v': true,
|
||||
'c': participantsCount,
|
||||
if (participantIds.isNotEmpty) 'p': participantIds,
|
||||
});
|
||||
|
||||
static ChatCall? decode(String? raw) {
|
||||
if (raw == null || raw.isEmpty) return null;
|
||||
try {
|
||||
final map = jsonDecode(raw);
|
||||
if (map is! Map) return null;
|
||||
final joinLink = map['l']?.toString() ?? '';
|
||||
if (joinLink.isEmpty) return null;
|
||||
return ChatCall(
|
||||
joinLink: joinLink,
|
||||
isVideo: map['v'] == true,
|
||||
participantsCount: parseIntOrNull(map['c']) ?? 0,
|
||||
participantIds: parseIntList(map['p']),
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import 'admin_rights.dart';
|
||||
|
||||
// #***! расширенная карточка чата
|
||||
class ChatInfo {
|
||||
final Map<String, dynamic> raw;
|
||||
final List<int> participantIds;
|
||||
final Set<int> adminIds;
|
||||
final int? owner;
|
||||
|
||||
const ChatInfo({
|
||||
required this.raw,
|
||||
required this.participantIds,
|
||||
required this.adminIds,
|
||||
required this.owner,
|
||||
});
|
||||
|
||||
factory ChatInfo.fromMap(Map<String, dynamic> map) {
|
||||
return ChatInfo(
|
||||
raw: map,
|
||||
participantIds: _idKeys(map['participants']),
|
||||
adminIds: _idKeys(map['adminParticipants']).toSet(),
|
||||
owner: map['owner'] as int?,
|
||||
);
|
||||
}
|
||||
|
||||
bool isAdmin(int id) => adminIds.contains(id);
|
||||
bool isOwner(int id) => owner != null && id == owner;
|
||||
|
||||
bool option(String name) {
|
||||
final opts = raw['options'];
|
||||
return opts is Map && opts[name] == true;
|
||||
}
|
||||
|
||||
bool canSeeInviteLink(int id) =>
|
||||
isAdmin(id) || isOwner(id) || option('MEMBERS_CAN_SEE_PRIVATE_LINK');
|
||||
|
||||
bool get isPublic => raw['access'] == 'PUBLIC';
|
||||
|
||||
bool canSeeLink(int id) => isPublic || canSeeInviteLink(id);
|
||||
|
||||
String? adminAlias(int id) {
|
||||
final entry = _adminEntry(id);
|
||||
if (entry == null) return null;
|
||||
final alias = entry['alias'];
|
||||
if (alias is String && alias.trim().isNotEmpty) return alias.trim();
|
||||
return null;
|
||||
}
|
||||
|
||||
AdminRights rightsOf(int id) {
|
||||
if (isOwner(id)) {
|
||||
return const AdminRights(AdminRights.ownerBits);
|
||||
}
|
||||
final permissions = _adminEntry(id)?['permissions'];
|
||||
return AdminRights(permissions is int ? permissions : 0);
|
||||
}
|
||||
|
||||
bool can(int id, AdminRight right) =>
|
||||
isOwner(id) || (isAdmin(id) && rightsOf(id).has(right));
|
||||
|
||||
Map<dynamic, dynamic>? _adminEntry(int id) {
|
||||
final source = raw['adminParticipants'];
|
||||
if (source is! Map) return null;
|
||||
final entry = source[id.toString()] ?? source[id];
|
||||
return entry is Map ? entry : null;
|
||||
}
|
||||
|
||||
int? get participantsCount => raw['participantsCount'] as int?;
|
||||
int? get blockedParticipantsCount => raw['blockedParticipantsCount'] as int?;
|
||||
// #***! счётчик заявок на вступление, приходит в объекте чата
|
||||
int get pendingJoinRequestsCount =>
|
||||
(raw['pendingJoinRequestsCount'] as int?) ?? 0;
|
||||
String? get link => raw['link'] as String?;
|
||||
bool get joinRequests => option('JOIN_REQUEST');
|
||||
bool get commentsEnabled => option('COMMENTS');
|
||||
String? get title => raw['title'] as String?;
|
||||
String? get iconUrl => raw['baseIconUrl'] as String?;
|
||||
AdminChatKind get adminKind =>
|
||||
raw['type'] == 'CHANNEL' ? AdminChatKind.channel : AdminChatKind.group;
|
||||
String? get description => raw['description'] as String?;
|
||||
|
||||
static List<int> _idKeys(Object? source) {
|
||||
if (source is! Map) return const [];
|
||||
final out = <int>[];
|
||||
for (final key in source.keys) {
|
||||
final id = key is int ? key : int.tryParse(key.toString());
|
||||
if (id != null) out.add(id);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -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,83 @@
|
||||
import '../core/utils/emoji_keyword_index.dart';
|
||||
|
||||
class ChatReactionSettings {
|
||||
static const int maxCount = 8;
|
||||
|
||||
final bool isActive;
|
||||
final int count;
|
||||
final bool included;
|
||||
final List<String> reactionIds;
|
||||
|
||||
const ChatReactionSettings({
|
||||
required this.isActive,
|
||||
required this.count,
|
||||
required this.included,
|
||||
required this.reactionIds,
|
||||
});
|
||||
|
||||
static ChatReactionSettings? fromMap(Object? raw) {
|
||||
if (raw is! Map) return null;
|
||||
final count = raw['count'];
|
||||
final ids = raw['reactionIds'];
|
||||
return ChatReactionSettings(
|
||||
isActive: raw['isActive'] != false,
|
||||
count: count is int ? count.clamp(1, maxCount) : maxCount,
|
||||
included: raw['included'] == true,
|
||||
reactionIds: ids is List
|
||||
? ids.map((id) => id.toString()).toList(growable: false)
|
||||
: const [],
|
||||
);
|
||||
}
|
||||
|
||||
bool get restricted => included || reactionIds.isNotEmpty;
|
||||
|
||||
bool Function(String emoji) filterFor(Iterable<String> present) {
|
||||
if (!isActive) return (_) => false;
|
||||
final listed = reactionIds.map(EmojiKeywordIndex.normalize).toSet();
|
||||
final onMessage = present.map(EmojiKeywordIndex.normalize).toSet();
|
||||
final full = onMessage.length >= count;
|
||||
return (emoji) {
|
||||
final key = EmojiKeywordIndex.normalize(emoji);
|
||||
if (listed.contains(key) != included) return false;
|
||||
return !full || onMessage.contains(key);
|
||||
};
|
||||
}
|
||||
|
||||
static List<String> presentWithoutMine(Object? reactionInfo) {
|
||||
if (reactionInfo is! Map) return const [];
|
||||
final counters = reactionInfo['counters'];
|
||||
if (counters is! List) return const [];
|
||||
final mine = reactionInfo['yourReaction']?.toString();
|
||||
final mineKey = mine == null ? null : EmojiKeywordIndex.normalize(mine);
|
||||
return [
|
||||
for (final counter in counters)
|
||||
if (counter is Map && counter['reaction'] != null)
|
||||
if (EmojiKeywordIndex.normalize(counter['reaction'].toString()) !=
|
||||
mineKey ||
|
||||
(counter['count'] is int && (counter['count'] as int) > 1))
|
||||
counter['reaction'].toString(),
|
||||
];
|
||||
}
|
||||
|
||||
bool allows(String emoji) => filterFor(const [])(emoji);
|
||||
|
||||
bool allowsOn(String emoji, Iterable<String> present) =>
|
||||
filterFor(present)(emoji);
|
||||
|
||||
Set<String> allowedOf(Iterable<String> catalog) {
|
||||
final listed = reactionIds.map(EmojiKeywordIndex.normalize).toSet();
|
||||
return {
|
||||
for (final emoji in catalog)
|
||||
if (listed.contains(EmojiKeywordIndex.normalize(emoji)) == included)
|
||||
emoji,
|
||||
};
|
||||
}
|
||||
|
||||
static List<String> forbiddenOf(
|
||||
Iterable<String> catalog,
|
||||
Set<String> allowed,
|
||||
) => [
|
||||
for (final emoji in catalog)
|
||||
if (!allowed.contains(emoji)) emoji,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import 'chat_info.dart';
|
||||
|
||||
enum ChatRestriction {
|
||||
forwardDisabled('DISABLE_FORWARD'),
|
||||
copyDisabled('MESSAGE_COPY_NOT_ALLOWED'),
|
||||
confirmBeforeSend('CONFIRM_BEFORE_SEND');
|
||||
|
||||
const ChatRestriction(this.option);
|
||||
|
||||
final String option;
|
||||
|
||||
bool enabledIn(ChatInfo info) => info.option(option);
|
||||
|
||||
Map<String, bool> optionFor(bool enabled) => {option: enabled};
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
class ContactName {
|
||||
final String? type;
|
||||
final String? name;
|
||||
final String? firstName;
|
||||
final String? lastName;
|
||||
|
||||
const ContactName({this.type, this.name, this.firstName, this.lastName});
|
||||
|
||||
factory ContactName.fromMap(Map map) => ContactName(
|
||||
type: map['type']?.toString(),
|
||||
name: map['name']?.toString(),
|
||||
firstName: map['firstName']?.toString(),
|
||||
lastName: map['lastName']?.toString(),
|
||||
);
|
||||
|
||||
String? get label {
|
||||
final n = name;
|
||||
if (n != null && n.trim().isNotEmpty) return n.trim();
|
||||
return fullName;
|
||||
}
|
||||
|
||||
String? get fullName {
|
||||
final combined = [firstName, lastName]
|
||||
.where((s) => s != null && s.trim().isNotEmpty)
|
||||
.map((s) => s!.trim())
|
||||
.join(' ');
|
||||
if (combined.isNotEmpty) return combined;
|
||||
final n = name;
|
||||
return (n != null && n.trim().isNotEmpty) ? n.trim() : null;
|
||||
}
|
||||
}
|
||||
|
||||
class ContactInfo {
|
||||
final Map<String, dynamic> raw;
|
||||
final List<ContactName> names;
|
||||
|
||||
const ContactInfo({required this.raw, required this.names});
|
||||
|
||||
factory ContactInfo.fromMap(Map<String, dynamic> map) {
|
||||
final rawNames = map['names'];
|
||||
final names = <ContactName>[];
|
||||
if (rawNames is List) {
|
||||
for (final n in rawNames) {
|
||||
if (n is Map) names.add(ContactName.fromMap(n));
|
||||
}
|
||||
}
|
||||
return ContactInfo(raw: map, names: names);
|
||||
}
|
||||
|
||||
String? get displayName {
|
||||
String? firstLabel;
|
||||
for (final n in names) {
|
||||
final label = n.label;
|
||||
if (label == null) continue;
|
||||
firstLabel ??= label;
|
||||
if (n.type == 'ONEME') return label;
|
||||
}
|
||||
return firstLabel;
|
||||
}
|
||||
|
||||
String? get customFullName => _fullNameOfType('CUSTOM');
|
||||
|
||||
String? get onemeFullName => _fullNameOfType('ONEME');
|
||||
|
||||
String? get fullName => customFullName ?? onemeFullName ?? displayName;
|
||||
|
||||
bool get isSavedContact => customFullName != null;
|
||||
|
||||
String? _fullNameOfType(String type) {
|
||||
for (final n in names) {
|
||||
if (n.type != type) continue;
|
||||
final full = n.fullName;
|
||||
if (full != null) return full;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? get firstName {
|
||||
for (final n in names) {
|
||||
final f = n.firstName;
|
||||
if (f != null && f.trim().isNotEmpty) return f.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? get avatarUrl => raw['baseUrl'] as String?;
|
||||
|
||||
List<String> get options {
|
||||
final o = raw['options'];
|
||||
return o is List ? o.whereType<String>().toList() : const [];
|
||||
}
|
||||
|
||||
bool get isBot => options.contains('BOT');
|
||||
|
||||
bool get isDeleted {
|
||||
final status = raw['accountStatus'];
|
||||
return status is int && status != 0;
|
||||
}
|
||||
|
||||
int? get id => raw['id'] as int?;
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
enum DigitalIdVerification {
|
||||
valid,
|
||||
invalid,
|
||||
photoCreated,
|
||||
notFound,
|
||||
deviceMismatch,
|
||||
liteValid,
|
||||
unknown;
|
||||
|
||||
static DigitalIdVerification fromValue(String? value) {
|
||||
switch (value) {
|
||||
case 'valid':
|
||||
return DigitalIdVerification.valid;
|
||||
case 'invalid':
|
||||
return DigitalIdVerification.invalid;
|
||||
case 'photo_created':
|
||||
return DigitalIdVerification.photoCreated;
|
||||
case 'not_found':
|
||||
return DigitalIdVerification.notFound;
|
||||
case 'device_mismatch':
|
||||
return DigitalIdVerification.deviceMismatch;
|
||||
case 'lite_valid':
|
||||
return DigitalIdVerification.liteValid;
|
||||
default:
|
||||
return DigitalIdVerification.unknown;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum DigitalIdQrType {
|
||||
m1('M1', 'max_confirm_age'),
|
||||
m2('M2', 'max_cert_large_family'),
|
||||
m3('M3', 'max_student_ticket'),
|
||||
m4('M4', 'max_sor'),
|
||||
m5('M5', 'max_single_benefits'),
|
||||
m7('M7', 'max_invalid_certificate'),
|
||||
m8('M8', 'max_pension_certificate'),
|
||||
m11('M11', 'max_identify_verification');
|
||||
|
||||
const DigitalIdQrType(this.code, this.alias);
|
||||
|
||||
final String code;
|
||||
final String alias;
|
||||
}
|
||||
|
||||
class DigitalIdAddress {
|
||||
final String? address;
|
||||
final String? flat;
|
||||
final String? frame;
|
||||
final String? house;
|
||||
final String? zipCode;
|
||||
|
||||
const DigitalIdAddress({
|
||||
this.address,
|
||||
this.flat,
|
||||
this.frame,
|
||||
this.house,
|
||||
this.zipCode,
|
||||
});
|
||||
|
||||
factory DigitalIdAddress.fromMap(Map map) {
|
||||
return DigitalIdAddress(
|
||||
address: map['address'] as String?,
|
||||
flat: map['flat'] as String?,
|
||||
frame: map['frame'] as String?,
|
||||
house: map['house'] as String?,
|
||||
zipCode: map['zip_code'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
String get formatted {
|
||||
final parts = <String>[
|
||||
if (address != null && address!.isNotEmpty) address!,
|
||||
if (house != null && house!.isNotEmpty) 'д. $house',
|
||||
if (frame != null && frame!.isNotEmpty) 'к. $frame',
|
||||
if (flat != null && flat!.isNotEmpty) 'кв. $flat',
|
||||
];
|
||||
return parts.join(', ');
|
||||
}
|
||||
}
|
||||
|
||||
class DigitalIdBiometryStatus {
|
||||
final bool hasBiometryToken;
|
||||
final String? deviceId;
|
||||
final bool hasPhotoHash;
|
||||
|
||||
const DigitalIdBiometryStatus({
|
||||
required this.hasBiometryToken,
|
||||
required this.deviceId,
|
||||
required this.hasPhotoHash,
|
||||
});
|
||||
|
||||
factory DigitalIdBiometryStatus.fromMap(Map map) {
|
||||
return DigitalIdBiometryStatus(
|
||||
hasBiometryToken: map['has_biometry_token'] == true,
|
||||
deviceId: map['device_id'] as String?,
|
||||
hasPhotoHash: map['has_photo_hash'] == true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DigitalIdDocument {
|
||||
final String type;
|
||||
final Map<String, dynamic> fields;
|
||||
|
||||
const DigitalIdDocument({required this.type, required this.fields});
|
||||
|
||||
factory DigitalIdDocument.fromMap(Map map) {
|
||||
final fields = <String, dynamic>{};
|
||||
for (final entry in map.entries) {
|
||||
fields[entry.key.toString()] = entry.value;
|
||||
}
|
||||
return DigitalIdDocument(
|
||||
type: (map['type'] as String?) ?? 'unknown',
|
||||
fields: fields,
|
||||
);
|
||||
}
|
||||
|
||||
String? get firstName => fields['first_name'] as String?;
|
||||
String? get lastName => fields['last_name'] as String?;
|
||||
String? get middleName => fields['middle_name'] as String?;
|
||||
String? get number => fields['number'] as String?;
|
||||
String? get series => fields['series'] as String?;
|
||||
}
|
||||
|
||||
class DigitalIdProfile {
|
||||
final String? firstName;
|
||||
final String? lastName;
|
||||
final String? middleName;
|
||||
final String? birthDate;
|
||||
final String? birthPlace;
|
||||
final String? gender;
|
||||
final String? snils;
|
||||
final String? inn;
|
||||
final DigitalIdAddress? registrationAddress;
|
||||
final List<DigitalIdDocument> documents;
|
||||
|
||||
const DigitalIdProfile({
|
||||
this.firstName,
|
||||
this.lastName,
|
||||
this.middleName,
|
||||
this.birthDate,
|
||||
this.birthPlace,
|
||||
this.gender,
|
||||
this.snils,
|
||||
this.inn,
|
||||
this.registrationAddress,
|
||||
this.documents = const [],
|
||||
});
|
||||
|
||||
factory DigitalIdProfile.fromMap(Map map) {
|
||||
final rawDocs = map['documents'];
|
||||
final documents = <DigitalIdDocument>[];
|
||||
if (rawDocs is List) {
|
||||
for (final doc in rawDocs) {
|
||||
if (doc is Map) documents.add(DigitalIdDocument.fromMap(doc));
|
||||
}
|
||||
}
|
||||
final address = map['registration_address'];
|
||||
return DigitalIdProfile(
|
||||
firstName: map['first_name'] as String?,
|
||||
lastName: map['last_name'] as String?,
|
||||
middleName: map['middle_name'] as String?,
|
||||
birthDate: map['birth_date'] as String?,
|
||||
birthPlace: map['birth_place'] as String?,
|
||||
gender: map['gender'] as String?,
|
||||
snils: map['snils'] as String?,
|
||||
inn: map['inn'] as String?,
|
||||
registrationAddress: address is Map
|
||||
? DigitalIdAddress.fromMap(address)
|
||||
: null,
|
||||
documents: documents,
|
||||
);
|
||||
}
|
||||
|
||||
String get fullName {
|
||||
final parts = <String>[
|
||||
if (lastName != null && lastName!.isNotEmpty) lastName!,
|
||||
if (firstName != null && firstName!.isNotEmpty) firstName!,
|
||||
if (middleName != null && middleName!.isNotEmpty) middleName!,
|
||||
];
|
||||
return parts.join(' ');
|
||||
}
|
||||
}
|
||||
|
||||
class DigitalIdUserDocs {
|
||||
final int userId;
|
||||
final DigitalIdProfile profile;
|
||||
|
||||
const DigitalIdUserDocs({required this.userId, required this.profile});
|
||||
|
||||
factory DigitalIdUserDocs.fromMap(Map map) {
|
||||
final profile = map['digital_profile'];
|
||||
return DigitalIdUserDocs(
|
||||
userId: (map['user_id'] as num?)?.toInt() ?? 0,
|
||||
profile: profile is Map
|
||||
? DigitalIdProfile.fromMap(profile)
|
||||
: const DigitalIdProfile(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DigitalIdEsiaLink {
|
||||
final String? state;
|
||||
final String url;
|
||||
|
||||
const DigitalIdEsiaLink({this.state, required this.url});
|
||||
|
||||
factory DigitalIdEsiaLink.fromMap(Map map) {
|
||||
return DigitalIdEsiaLink(
|
||||
state: map['state'] as String?,
|
||||
url: map['url'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DigitalIdQr {
|
||||
final String qr;
|
||||
final String? qrGost;
|
||||
|
||||
const DigitalIdQr({required this.qr, this.qrGost});
|
||||
|
||||
factory DigitalIdQr.fromMap(Map map) {
|
||||
return DigitalIdQr(
|
||||
qr: map['qr'] as String? ?? '',
|
||||
qrGost: map['qr_gost'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DigitalIdUniversalQr {
|
||||
final String uidHash;
|
||||
final String? phone;
|
||||
final String? sessionId;
|
||||
|
||||
const DigitalIdUniversalQr({
|
||||
required this.uidHash,
|
||||
this.phone,
|
||||
this.sessionId,
|
||||
});
|
||||
|
||||
factory DigitalIdUniversalQr.fromMap(Map map) {
|
||||
return DigitalIdUniversalQr(
|
||||
uidHash: map['uid_hash'] as String? ?? '',
|
||||
phone: map['phone'] as String?,
|
||||
sessionId: map['session_id'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DigitalIdAcmsCard {
|
||||
final String id;
|
||||
final String inn;
|
||||
final String companyName;
|
||||
final String logoImg;
|
||||
|
||||
const DigitalIdAcmsCard({
|
||||
required this.id,
|
||||
required this.inn,
|
||||
required this.companyName,
|
||||
required this.logoImg,
|
||||
});
|
||||
|
||||
factory DigitalIdAcmsCard.fromMap(Map map) {
|
||||
return DigitalIdAcmsCard(
|
||||
id: map['id'] as String? ?? '',
|
||||
inn: map['inn'] as String? ?? '',
|
||||
companyName: map['company_name'] as String? ?? '',
|
||||
logoImg: map['logo_img'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
abstract class BannerType {
|
||||
static const int text = 0;
|
||||
static const int link = 1;
|
||||
static const int update = 2;
|
||||
}
|
||||
|
||||
abstract class BannerSettings {
|
||||
static const int textAnimation = 1;
|
||||
static const int hideCloseButton = 2;
|
||||
static const int hideOnClick = 4;
|
||||
static const int iconThemeColor = 8;
|
||||
}
|
||||
|
||||
class InformerBanner {
|
||||
final String id;
|
||||
final String title;
|
||||
final String description;
|
||||
final int settings;
|
||||
final int priority;
|
||||
final int repeat;
|
||||
final int rerun;
|
||||
final int? animojiId;
|
||||
final String? url;
|
||||
final int type;
|
||||
|
||||
const InformerBanner({
|
||||
required this.id,
|
||||
required this.title,
|
||||
this.description = '',
|
||||
this.settings = 0,
|
||||
this.priority = 0,
|
||||
this.repeat = 0,
|
||||
this.rerun = 0,
|
||||
this.animojiId,
|
||||
this.url,
|
||||
this.type = BannerType.text,
|
||||
});
|
||||
|
||||
bool get animatesText => settings & BannerSettings.textAnimation != 0;
|
||||
bool get hidesCloseButton => settings & BannerSettings.hideCloseButton != 0;
|
||||
bool get closesOnClick => settings & BannerSettings.hideOnClick != 0;
|
||||
bool get tintsIconWithTheme => settings & BannerSettings.iconThemeColor != 0;
|
||||
|
||||
bool get isLink =>
|
||||
type == BannerType.link && url != null && url!.trim().isNotEmpty;
|
||||
bool get isUpdate => type == BannerType.update;
|
||||
bool get isClickable => isLink || isUpdate;
|
||||
|
||||
static InformerBanner? fromMap(Map<dynamic, dynamic> map) {
|
||||
final id = map['id']?.toString();
|
||||
if (id == null || id.isEmpty) return null;
|
||||
final title = map['title']?.toString() ?? '';
|
||||
final description = map['description']?.toString() ?? '';
|
||||
if (title.isEmpty && description.isEmpty) return null;
|
||||
return InformerBanner(
|
||||
id: id,
|
||||
title: title,
|
||||
description: description,
|
||||
settings: _int(map['settings']) ?? 0,
|
||||
priority: _int(map['priority']) ?? 0,
|
||||
repeat: _int(map['repeat']) ?? 0,
|
||||
rerun: _int(map['rerun']) ?? 0,
|
||||
animojiId: _int(map['animojiId']),
|
||||
url: map['url']?.toString(),
|
||||
type: _int(map['type']) ?? BannerType.text,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'title': title,
|
||||
'description': description,
|
||||
'settings': settings,
|
||||
'priority': priority,
|
||||
'repeat': repeat,
|
||||
'rerun': rerun,
|
||||
'animojiId': animojiId,
|
||||
'url': url,
|
||||
'type': type,
|
||||
};
|
||||
|
||||
static int? _int(dynamic value) {
|
||||
if (value is int) return value;
|
||||
if (value is num) return value.toInt();
|
||||
if (value is String) return int.tryParse(value);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class BannerShowState {
|
||||
final int showCounter;
|
||||
final int? showAt;
|
||||
final int? closedAt;
|
||||
|
||||
const BannerShowState({this.showCounter = 0, this.showAt, this.closedAt});
|
||||
|
||||
BannerShowState copyWith({int? showCounter, int? showAt, int? closedAt}) =>
|
||||
BannerShowState(
|
||||
showCounter: showCounter ?? this.showCounter,
|
||||
showAt: showAt ?? this.showAt,
|
||||
closedAt: closedAt ?? this.closedAt,
|
||||
);
|
||||
|
||||
static BannerShowState fromJson(Map<dynamic, dynamic> map) => BannerShowState(
|
||||
showCounter: InformerBanner._int(map['showCounter']) ?? 0,
|
||||
showAt: InformerBanner._int(map['showAt']),
|
||||
closedAt: InformerBanner._int(map['closedTime']),
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'showCounter': showCounter,
|
||||
'showAt': showAt,
|
||||
'closedTime': closedAt,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
class LoginInfo {
|
||||
const LoginInfo._();
|
||||
|
||||
static Map<String, dynamic> fromPayload(Map<dynamic, dynamic> payload) {
|
||||
final profile = _asMap(payload['profile']);
|
||||
final contact = _asMap(profile?['contact']);
|
||||
final chats = _asList(payload['chats']);
|
||||
final config = _asMap(payload['config']);
|
||||
final server = _asMap(config?['server']);
|
||||
final user = _asMap(config?['user']);
|
||||
final experiments = _asMap(config?['experiments']);
|
||||
final chatSettings = _asMap(config?['chats']);
|
||||
|
||||
return {
|
||||
'registrationTime': contact?['registrationTime'],
|
||||
'country': contact?['country'],
|
||||
'videoChatHistory': payload['videoChatHistory'],
|
||||
'updateTime': contact?['updateTime'],
|
||||
'id': contact?['id'],
|
||||
'phone': contact?['phone'],
|
||||
'photoId': contact?['photoId'],
|
||||
'accountStatus': contact?['accountStatus'],
|
||||
'contactOptions': _copyJsonValue(contact?['options']),
|
||||
'profileOptions': _copyJsonValue(profile?['profileOptions']),
|
||||
'names': _copyJsonValue(contact?['names']),
|
||||
'baseUrl': contact?['baseUrl'],
|
||||
'baseRawUrl': contact?['baseRawUrl'],
|
||||
'chatMarker': payload['chatMarker'] ?? _latestChatEventTime(chats),
|
||||
'time': payload['time'],
|
||||
'updates': payload['updates'],
|
||||
'messagesCount': _collectionLength(payload['messages']),
|
||||
'contactsCount': _collectionLength(payload['contacts']),
|
||||
'presenceCount': _collectionLength(payload['presence']),
|
||||
'configHash': config?['hash'],
|
||||
'chats': _buildChatsSummary(chats),
|
||||
'server': _copyMap(server),
|
||||
'user': _copyMap(user),
|
||||
'experiments': _copyMap(experiments),
|
||||
'chatSettings': _copyMap(chatSettings),
|
||||
};
|
||||
}
|
||||
|
||||
static Map<String, dynamic> _buildChatsSummary(List<dynamic> chats) {
|
||||
var active = 0;
|
||||
var hidden = 0;
|
||||
var dialogs = 0;
|
||||
var groups = 0;
|
||||
var channels = 0;
|
||||
var unread = 0;
|
||||
var newMessages = 0;
|
||||
var messages = 0;
|
||||
|
||||
for (final rawChat in chats) {
|
||||
final chat = _asMap(rawChat);
|
||||
if (chat == null) continue;
|
||||
switch (chat['status']) {
|
||||
case 'ACTIVE':
|
||||
active++;
|
||||
case 'HIDDEN':
|
||||
hidden++;
|
||||
}
|
||||
switch (chat['type']) {
|
||||
case 'DIALOG':
|
||||
dialogs++;
|
||||
case 'CHAT':
|
||||
groups++;
|
||||
case 'CHANNEL':
|
||||
channels++;
|
||||
}
|
||||
final chatNewMessages = _asInt(chat['newMessages']) ?? 0;
|
||||
if (chatNewMessages > 0) unread++;
|
||||
newMessages += chatNewMessages;
|
||||
messages += _asInt(chat['messagesCount']) ?? 0;
|
||||
}
|
||||
|
||||
return {
|
||||
'count': chats.length,
|
||||
'active': active,
|
||||
'hidden': hidden,
|
||||
'dialogs': dialogs,
|
||||
'groups': groups,
|
||||
'channels': channels,
|
||||
'unread': unread,
|
||||
'newMessages': newMessages,
|
||||
'messages': messages,
|
||||
};
|
||||
}
|
||||
|
||||
static int? _latestChatEventTime(List<dynamic> chats) {
|
||||
int? latest;
|
||||
for (final rawChat in chats) {
|
||||
final chat = _asMap(rawChat);
|
||||
final value = _asInt(chat?['lastEventTime']);
|
||||
if (value != null && (latest == null || value > latest)) {
|
||||
latest = value;
|
||||
}
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
static Map<dynamic, dynamic>? _asMap(dynamic value) {
|
||||
return value is Map ? value : null;
|
||||
}
|
||||
|
||||
static List<dynamic> _asList(dynamic value) {
|
||||
return value is List ? value : const [];
|
||||
}
|
||||
|
||||
static int? _asInt(dynamic value) {
|
||||
return switch (value) {
|
||||
int number => number,
|
||||
num number => number.toInt(),
|
||||
String text => int.tryParse(text),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
static int _collectionLength(dynamic value) {
|
||||
return switch (value) {
|
||||
Map items => items.length,
|
||||
List items => items.length,
|
||||
_ => 0,
|
||||
};
|
||||
}
|
||||
|
||||
static Map<String, dynamic>? _copyMap(Map<dynamic, dynamic>? value) {
|
||||
if (value == null) return null;
|
||||
return value.map(
|
||||
(key, item) => MapEntry(key.toString(), _copyJsonValue(item)),
|
||||
);
|
||||
}
|
||||
|
||||
static dynamic _copyJsonValue(dynamic value) {
|
||||
if (value is Map) return _copyMap(value);
|
||||
if (value is List) return value.map(_copyJsonValue).toList();
|
||||
if (value == null || value is String || value is num || value is bool) {
|
||||
return value;
|
||||
}
|
||||
return value.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import 'chat_info.dart';
|
||||
|
||||
enum MemberPermission {
|
||||
editInfo('ONLY_OWNER_CAN_CHANGE_ICON_TITLE', inverted: true),
|
||||
addMembers('ONLY_ADMIN_CAN_ADD_MEMBER', inverted: true),
|
||||
pinMessages('ALL_CAN_PIN_MESSAGE'),
|
||||
inviteByLink('MEMBERS_CAN_SEE_PRIVATE_LINK'),
|
||||
call('ONLY_ADMIN_CAN_CALL', inverted: true);
|
||||
|
||||
const MemberPermission(this.option, {this.inverted = false});
|
||||
|
||||
final String option;
|
||||
final bool inverted;
|
||||
|
||||
bool allowedIn(ChatInfo info) => info.option(option) != inverted;
|
||||
|
||||
Map<String, bool> optionFor(bool allowed) => {option: allowed != inverted};
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
class PollAnswer {
|
||||
final int answerId;
|
||||
final String text;
|
||||
final int voteCount;
|
||||
final double rate;
|
||||
final List<int> votes;
|
||||
final bool mine;
|
||||
|
||||
const PollAnswer({
|
||||
required this.answerId,
|
||||
required this.text,
|
||||
this.voteCount = 0,
|
||||
this.rate = 0,
|
||||
this.votes = const [],
|
||||
this.mine = false,
|
||||
});
|
||||
}
|
||||
|
||||
class Poll {
|
||||
final int pollId;
|
||||
final String title;
|
||||
final int settings;
|
||||
final int version;
|
||||
final int total;
|
||||
final List<PollAnswer> answers;
|
||||
final List<int> voterPreviewIds;
|
||||
|
||||
const Poll({
|
||||
required this.pollId,
|
||||
required this.title,
|
||||
this.settings = 0,
|
||||
this.version = 0,
|
||||
this.total = 0,
|
||||
this.answers = const [],
|
||||
this.voterPreviewIds = const [],
|
||||
});
|
||||
|
||||
bool get isMultiple => settings & 0x1 != 0;
|
||||
|
||||
bool get hasMyVote => answers.any((a) => a.mine);
|
||||
|
||||
bool votedBy(int 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 _buildFromState(
|
||||
pollId: pollId,
|
||||
title: title,
|
||||
settings: settings,
|
||||
version: version,
|
||||
answerIdsAndTexts: [for (final a in answers) (a.answerId, a.text)],
|
||||
stateMap: stateMap,
|
||||
);
|
||||
}
|
||||
|
||||
factory Poll.fromServerMap(Map<dynamic, dynamic> map) {
|
||||
final state = map['state'];
|
||||
final stateMap = state is Map ? state : const {};
|
||||
|
||||
final answerIdsAndTexts = <(int, String)>[];
|
||||
final rawAnswers = map['answers'];
|
||||
if (rawAnswers is List) {
|
||||
for (final a in rawAnswers) {
|
||||
if (a is! Map) continue;
|
||||
answerIdsAndTexts.add((
|
||||
a['answerId'] as int? ?? 0,
|
||||
a['text']?.toString() ?? '',
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
return _buildFromState(
|
||||
pollId: map['pollId'] as int? ?? 0,
|
||||
title: map['title']?.toString() ?? '',
|
||||
settings: map['settings'] as int? ?? 0,
|
||||
version: map['version'] as int? ?? 0,
|
||||
answerIdsAndTexts: answerIdsAndTexts,
|
||||
stateMap: stateMap,
|
||||
);
|
||||
}
|
||||
|
||||
static Poll _buildFromState({
|
||||
required int pollId,
|
||||
required String title,
|
||||
required int settings,
|
||||
required int version,
|
||||
required List<(int, String)> answerIdsAndTexts,
|
||||
required Map<dynamic, dynamic> stateMap,
|
||||
}) {
|
||||
final resultsById = <int, Map>{};
|
||||
final result = stateMap['result'];
|
||||
if (result is List) {
|
||||
for (final r in result) {
|
||||
if (r is Map && r['answerId'] is int) {
|
||||
resultsById[r['answerId'] as int] = r;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final answers = <PollAnswer>[];
|
||||
for (final (id, text) in answerIdsAndTexts) {
|
||||
final res = resultsById[id];
|
||||
answers.add(
|
||||
PollAnswer(
|
||||
answerId: id,
|
||||
text: text,
|
||||
voteCount: (res?['voteCount'] as num?)?.toInt() ?? 0,
|
||||
rate: (res?['rate'] as num?)?.toDouble() ?? 0,
|
||||
votes: _parseVoterIds(res?['votes']),
|
||||
mine: ((res?['options'] as num?)?.toInt() ?? 0) & 0x1 != 0,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Poll(
|
||||
pollId: pollId,
|
||||
title: title,
|
||||
settings: settings,
|
||||
version: version,
|
||||
total: (stateMap['total'] as num?)?.toInt() ?? 0,
|
||||
answers: answers,
|
||||
voterPreviewIds:
|
||||
(stateMap['voterPreviewIds'] as List?)?.whereType<int>().toList() ??
|
||||
const [],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
class ReactionCounter {
|
||||
final String reaction;
|
||||
final int count;
|
||||
const ReactionCounter({required this.reaction, required this.count});
|
||||
}
|
||||
|
||||
class ReactionInfo {
|
||||
final List<ReactionCounter> counters;
|
||||
final String? yourReaction;
|
||||
final int totalCount;
|
||||
|
||||
const ReactionInfo({
|
||||
this.counters = const [],
|
||||
this.yourReaction,
|
||||
this.totalCount = 0,
|
||||
});
|
||||
|
||||
static ReactionInfo? fromMap(Map? map) {
|
||||
if (map == null) return null;
|
||||
final rawCounters = map['counters'];
|
||||
if (rawCounters is! List || rawCounters.isEmpty) return null;
|
||||
final counters = <ReactionCounter>[];
|
||||
for (final c in rawCounters) {
|
||||
if (c is! Map) continue;
|
||||
final reaction = c['reaction']?.toString();
|
||||
if (reaction == null || reaction.isEmpty) continue;
|
||||
final rawCount = c['count'];
|
||||
counters.add(
|
||||
ReactionCounter(
|
||||
reaction: reaction,
|
||||
count: rawCount is int ? rawCount : 0,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (counters.isEmpty) return null;
|
||||
final total = map['totalCount'];
|
||||
return ReactionInfo(
|
||||
counters: counters,
|
||||
yourReaction: map['yourReaction']?.toString(),
|
||||
totalCount: total is int ? total : 0,
|
||||
);
|
||||
}
|
||||
|
||||
bool get isEmpty => counters.isEmpty;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import 'dart:io';
|
||||
|
||||
enum SharedFileKind { photo, video, file }
|
||||
|
||||
class SharedFile {
|
||||
final String path;
|
||||
final String name;
|
||||
final String mime;
|
||||
final int size;
|
||||
|
||||
const SharedFile({
|
||||
required this.path,
|
||||
required this.name,
|
||||
required this.mime,
|
||||
required this.size,
|
||||
});
|
||||
|
||||
static SharedFile? fromMap(Map<dynamic, dynamic> map) {
|
||||
final path = map['path'];
|
||||
if (path is! String || path.isEmpty) return null;
|
||||
final name = map['name'];
|
||||
final mime = map['mime'];
|
||||
final size = map['size'];
|
||||
return SharedFile(
|
||||
path: path,
|
||||
name: name is String && name.isNotEmpty ? name : _basename(path),
|
||||
mime: mime is String && mime.isNotEmpty
|
||||
? mime
|
||||
: 'application/octet-stream',
|
||||
size: size is int ? size : 0,
|
||||
);
|
||||
}
|
||||
|
||||
File get file => File(path);
|
||||
|
||||
SharedFileKind get kind {
|
||||
if (mime.startsWith('image/') && !mime.contains('svg')) {
|
||||
return SharedFileKind.photo;
|
||||
}
|
||||
if (mime.startsWith('video/')) return SharedFileKind.video;
|
||||
return SharedFileKind.file;
|
||||
}
|
||||
|
||||
static String _basename(String path) {
|
||||
final idx = path.lastIndexOf(Platform.pathSeparator);
|
||||
return idx < 0 ? path : path.substring(idx + 1);
|
||||
}
|
||||
}
|
||||
|
||||
class SharedPayload {
|
||||
final List<SharedFile> files;
|
||||
final String? text;
|
||||
final String? subject;
|
||||
|
||||
const SharedPayload({this.files = const [], this.text, this.subject});
|
||||
|
||||
static SharedPayload? fromMap(Object? raw) {
|
||||
if (raw is! Map) return null;
|
||||
final rawFiles = raw['files'];
|
||||
final files = <SharedFile>[];
|
||||
if (rawFiles is List) {
|
||||
for (final entry in rawFiles) {
|
||||
if (entry is! Map) continue;
|
||||
final file = SharedFile.fromMap(entry);
|
||||
if (file != null && file.file.existsSync()) files.add(file);
|
||||
}
|
||||
}
|
||||
final text = raw['text'];
|
||||
final subject = raw['subject'];
|
||||
final payload = SharedPayload(
|
||||
files: files,
|
||||
text: text is String && text.trim().isNotEmpty ? text.trim() : null,
|
||||
subject: subject is String && subject.trim().isNotEmpty
|
||||
? subject.trim()
|
||||
: null,
|
||||
);
|
||||
return payload.isEmpty ? null : payload;
|
||||
}
|
||||
|
||||
bool get isEmpty => files.isEmpty && text == null;
|
||||
|
||||
bool get isTextOnly => files.isEmpty && text != null;
|
||||
|
||||
List<SharedFile> get photos =>
|
||||
files.where((f) => f.kind == SharedFileKind.photo).toList();
|
||||
|
||||
List<SharedFile> get videos =>
|
||||
files.where((f) => f.kind == SharedFileKind.video).toList();
|
||||
|
||||
List<SharedFile> get documents =>
|
||||
files.where((f) => f.kind == SharedFileKind.file).toList();
|
||||
|
||||
SharedFileKind? get dominantKind {
|
||||
if (files.isEmpty) return null;
|
||||
if (documents.isNotEmpty) return SharedFileKind.file;
|
||||
if (videos.isNotEmpty && photos.isEmpty) return SharedFileKind.video;
|
||||
if (photos.isNotEmpty && videos.isEmpty) return SharedFileKind.photo;
|
||||
return SharedFileKind.photo;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
class SpoofProfile {
|
||||
final bool enabled;
|
||||
final String deviceName;
|
||||
final String osVersion;
|
||||
final String screen;
|
||||
final String timezone;
|
||||
final String locale;
|
||||
final String deviceLocale;
|
||||
final String deviceId;
|
||||
final String deviceType;
|
||||
final String arch;
|
||||
final String appVersion;
|
||||
final int buildNumber;
|
||||
final String pushDeviceType;
|
||||
final String instanceId;
|
||||
final int? clientSessionId;
|
||||
final String userAgent;
|
||||
|
||||
const SpoofProfile({
|
||||
required this.enabled,
|
||||
this.deviceName = '',
|
||||
this.osVersion = '',
|
||||
this.screen = '',
|
||||
this.timezone = '',
|
||||
this.locale = '',
|
||||
this.deviceLocale = '',
|
||||
this.deviceId = '',
|
||||
this.deviceType = 'ANDROID',
|
||||
this.arch = 'arm64-v8a',
|
||||
this.appVersion = '',
|
||||
this.buildNumber = 0,
|
||||
this.pushDeviceType = 'GCM',
|
||||
this.instanceId = '',
|
||||
this.clientSessionId,
|
||||
this.userAgent = '',
|
||||
});
|
||||
|
||||
SpoofProfile copyWith({
|
||||
bool? enabled,
|
||||
String? deviceName,
|
||||
String? osVersion,
|
||||
String? screen,
|
||||
String? timezone,
|
||||
String? locale,
|
||||
String? deviceLocale,
|
||||
String? deviceId,
|
||||
String? deviceType,
|
||||
String? arch,
|
||||
String? appVersion,
|
||||
int? buildNumber,
|
||||
String? pushDeviceType,
|
||||
String? instanceId,
|
||||
int? clientSessionId,
|
||||
String? userAgent,
|
||||
}) => SpoofProfile(
|
||||
enabled: enabled ?? this.enabled,
|
||||
deviceName: deviceName ?? this.deviceName,
|
||||
osVersion: osVersion ?? this.osVersion,
|
||||
screen: screen ?? this.screen,
|
||||
timezone: timezone ?? this.timezone,
|
||||
locale: locale ?? this.locale,
|
||||
deviceLocale: deviceLocale ?? this.deviceLocale,
|
||||
deviceId: deviceId ?? this.deviceId,
|
||||
deviceType: deviceType ?? this.deviceType,
|
||||
arch: arch ?? this.arch,
|
||||
appVersion: appVersion ?? this.appVersion,
|
||||
buildNumber: buildNumber ?? this.buildNumber,
|
||||
pushDeviceType: pushDeviceType ?? this.pushDeviceType,
|
||||
instanceId: instanceId ?? this.instanceId,
|
||||
clientSessionId: clientSessionId ?? this.clientSessionId,
|
||||
userAgent: userAgent ?? this.userAgent,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'enabled': enabled,
|
||||
'device_name': deviceName,
|
||||
'os_version': osVersion,
|
||||
'screen': screen,
|
||||
'timezone': timezone,
|
||||
'locale': locale,
|
||||
'device_locale': deviceLocale,
|
||||
'device_id': deviceId,
|
||||
'device_type': deviceType,
|
||||
'arch': arch,
|
||||
'app_version': appVersion,
|
||||
'build_number': buildNumber,
|
||||
'push_device_type': pushDeviceType,
|
||||
'instance_id': instanceId,
|
||||
'client_session_id': clientSessionId,
|
||||
'user_agent': userAgent,
|
||||
};
|
||||
|
||||
factory SpoofProfile.fromJson(Map<String, dynamic> json) => SpoofProfile(
|
||||
enabled: json['enabled'] as bool? ?? false,
|
||||
deviceName: json['device_name'] as String? ?? '',
|
||||
osVersion: json['os_version'] as String? ?? '',
|
||||
screen: json['screen'] as String? ?? '',
|
||||
timezone: json['timezone'] as String? ?? '',
|
||||
locale: json['locale'] as String? ?? '',
|
||||
deviceLocale: json['device_locale'] as String? ?? '',
|
||||
deviceId: json['device_id'] as String? ?? '',
|
||||
deviceType: json['device_type'] as String? ?? 'ANDROID',
|
||||
arch: json['arch'] as String? ?? 'arm64-v8a',
|
||||
appVersion: json['app_version'] as String? ?? '',
|
||||
buildNumber: (json['build_number'] as num?)?.toInt() ?? 0,
|
||||
pushDeviceType: json['push_device_type'] as String? ?? 'GCM',
|
||||
instanceId: json['instance_id'] as String? ?? '',
|
||||
clientSessionId: (json['client_session_id'] as num?)?.toInt(),
|
||||
userAgent: json['user_agent'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
class StickerSet {
|
||||
final int id;
|
||||
final String name;
|
||||
final String iconUrl;
|
||||
final List<int> stickerIds;
|
||||
final String? link;
|
||||
|
||||
const StickerSet({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.iconUrl,
|
||||
required this.stickerIds,
|
||||
this.link,
|
||||
});
|
||||
|
||||
factory StickerSet.fromMap(Map<dynamic, dynamic> map) {
|
||||
final rawStickers = map['stickers'];
|
||||
final ids = <int>[];
|
||||
if (rawStickers is List) {
|
||||
for (final e in rawStickers) {
|
||||
if (e is int) ids.add(e);
|
||||
}
|
||||
}
|
||||
return StickerSet(
|
||||
id: map['id'] as int,
|
||||
name: map['name']?.toString() ?? '',
|
||||
iconUrl: map['iconUrl']?.toString() ?? '',
|
||||
stickerIds: ids,
|
||||
link: map['link']?.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class StickerItem {
|
||||
final int id;
|
||||
final String url;
|
||||
final String? lottieUrl;
|
||||
final int? setId;
|
||||
final int? width;
|
||||
final int? height;
|
||||
final List<String> tags;
|
||||
|
||||
const StickerItem({
|
||||
required this.id,
|
||||
required this.url,
|
||||
this.lottieUrl,
|
||||
this.setId,
|
||||
this.width,
|
||||
this.height,
|
||||
this.tags = const [],
|
||||
});
|
||||
|
||||
bool get isAnimated => lottieUrl != null && lottieUrl!.isNotEmpty;
|
||||
|
||||
factory StickerItem.fromMap(Map<dynamic, dynamic> map) => StickerItem(
|
||||
id: map['id'] as int,
|
||||
url: map['url']?.toString() ?? '',
|
||||
lottieUrl: map['lottieUrl']?.toString(),
|
||||
setId: map['setId'] as int?,
|
||||
width: map['width'] as int?,
|
||||
height: map['height'] as int?,
|
||||
tags: _parseTags(map['tags']),
|
||||
);
|
||||
|
||||
static List<String> _parseTags(dynamic raw) {
|
||||
if (raw is! List) return const [];
|
||||
final result = <String>[];
|
||||
for (final e in raw) {
|
||||
final s = e?.toString();
|
||||
if (s != null && s.isNotEmpty) result.add(s);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
import '../core/utils/parse.dart';
|
||||
import 'attachment.dart';
|
||||
|
||||
enum StoryOwnerType { user, chat, channel }
|
||||
|
||||
int _ownerTypeToInt(StoryOwnerType type) {
|
||||
switch (type) {
|
||||
case StoryOwnerType.user:
|
||||
return 0;
|
||||
case StoryOwnerType.chat:
|
||||
return 1;
|
||||
case StoryOwnerType.channel:
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
StoryOwnerType _ownerTypeFromInt(Object? raw) {
|
||||
switch (parseIntOrNull(raw)) {
|
||||
case 1:
|
||||
return StoryOwnerType.chat;
|
||||
case 2:
|
||||
return StoryOwnerType.channel;
|
||||
default:
|
||||
return StoryOwnerType.user;
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> _asStringMap(Object? raw) {
|
||||
if (raw is Map<String, dynamic>) return raw;
|
||||
if (raw is Map) return Map<String, dynamic>.from(raw);
|
||||
return const {};
|
||||
}
|
||||
|
||||
class StoryOwner {
|
||||
final int ownerId;
|
||||
final StoryOwnerType type;
|
||||
|
||||
const StoryOwner({required this.ownerId, this.type = StoryOwnerType.user});
|
||||
|
||||
bool get isUser => type == StoryOwnerType.user;
|
||||
|
||||
static StoryOwner? fromMap(Object? raw) {
|
||||
final map = _asStringMap(raw);
|
||||
final id = parseIntOrNull(map['ownerId']);
|
||||
if (id == null || id == 0) return null;
|
||||
return StoryOwner(ownerId: id, type: _ownerTypeFromInt(map['type']));
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() => {
|
||||
'ownerId': ownerId,
|
||||
'type': _ownerTypeToInt(type),
|
||||
};
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is StoryOwner && other.ownerId == ownerId && other.type == type;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(ownerId, type);
|
||||
}
|
||||
|
||||
class StoryReaction {
|
||||
final int reactionType; // 0 = emoji, 1 = sticker
|
||||
final String id;
|
||||
|
||||
const StoryReaction({this.reactionType = 0, required this.id});
|
||||
|
||||
bool get isSticker => reactionType == 1;
|
||||
|
||||
static StoryReaction? fromMap(Object? raw) {
|
||||
final map = _asStringMap(raw);
|
||||
final id = map['id']?.toString();
|
||||
if (id == null || id.isEmpty) return null;
|
||||
return StoryReaction(
|
||||
reactionType: parseIntOrNull(map['reactionType']) ?? 0,
|
||||
id: id,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() => {'reactionType': reactionType, 'id': id};
|
||||
}
|
||||
|
||||
class StoryMedia {
|
||||
final AttachmentType type;
|
||||
final String? url;
|
||||
final String? thumbnailUrl;
|
||||
final String? previewData;
|
||||
final int? width;
|
||||
final int? height;
|
||||
final int? durationMs;
|
||||
|
||||
const StoryMedia({
|
||||
required this.type,
|
||||
this.url,
|
||||
this.thumbnailUrl,
|
||||
this.previewData,
|
||||
this.width,
|
||||
this.height,
|
||||
this.durationMs,
|
||||
});
|
||||
|
||||
bool get isVideo => type == AttachmentType.video;
|
||||
bool get isPhoto => type == AttachmentType.photo;
|
||||
|
||||
double get aspectRatio {
|
||||
final w = width ?? 0;
|
||||
final h = height ?? 0;
|
||||
if (w <= 0 || h <= 0) return 9 / 16;
|
||||
return w / h;
|
||||
}
|
||||
|
||||
static StoryMedia? fromMap(Object? raw) {
|
||||
final map = _asStringMap(raw);
|
||||
final typeStr = (map['_type'] as String? ?? '').toUpperCase();
|
||||
final previewData = decodeAttachPreview(map['previewData']);
|
||||
final width = parseIntOrNull(map['width']);
|
||||
final height = parseIntOrNull(map['height']);
|
||||
switch (typeStr) {
|
||||
case 'PHOTO':
|
||||
final url = (map['photoUrl'] ?? map['baseUrl'] ?? map['url'])
|
||||
?.toString();
|
||||
return StoryMedia(
|
||||
type: AttachmentType.photo,
|
||||
url: url,
|
||||
previewData: previewData,
|
||||
width: width,
|
||||
height: height,
|
||||
);
|
||||
case 'VIDEO':
|
||||
final url =
|
||||
(map['mp4Url'] ??
|
||||
map['videoUrl'] ??
|
||||
map['MP4_1080'] ??
|
||||
map['baseUrl'])
|
||||
?.toString();
|
||||
return StoryMedia(
|
||||
type: AttachmentType.video,
|
||||
url: url,
|
||||
thumbnailUrl: map['thumbnail']?.toString(),
|
||||
previewData: previewData,
|
||||
width: width,
|
||||
height: height,
|
||||
durationMs: parseIntOrNull(map['duration']),
|
||||
);
|
||||
default:
|
||||
return StoryMedia(
|
||||
type: AttachmentType.unknown,
|
||||
previewData: previewData,
|
||||
width: width,
|
||||
height: height,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String get _typeName {
|
||||
switch (type) {
|
||||
case AttachmentType.photo:
|
||||
return 'PHOTO';
|
||||
case AttachmentType.video:
|
||||
return 'VIDEO';
|
||||
default:
|
||||
return 'UNKNOWN';
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final map = <String, dynamic>{
|
||||
'_type': _typeName,
|
||||
if (previewData != null) 'previewData': previewData,
|
||||
if (width != null) 'width': width,
|
||||
if (height != null) 'height': height,
|
||||
};
|
||||
if (isVideo) {
|
||||
if (url != null) map['mp4Url'] = url;
|
||||
if (thumbnailUrl != null) map['thumbnail'] = thumbnailUrl;
|
||||
if (durationMs != null) map['duration'] = durationMs;
|
||||
} else {
|
||||
if (url != null) map['photoUrl'] = url;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
}
|
||||
|
||||
class Story {
|
||||
final int id;
|
||||
final int cid;
|
||||
final StoryOwner owner;
|
||||
final int settings;
|
||||
final int time;
|
||||
final int updateTime;
|
||||
final int expiration;
|
||||
final StoryMedia? media;
|
||||
final StoryReaction? reaction;
|
||||
|
||||
const Story({
|
||||
required this.id,
|
||||
required this.owner,
|
||||
this.cid = 0,
|
||||
this.settings = 0,
|
||||
this.time = 0,
|
||||
this.updateTime = 0,
|
||||
this.expiration = 0,
|
||||
this.media,
|
||||
this.reaction,
|
||||
});
|
||||
|
||||
Story copyWith({StoryReaction? reaction, bool clearReaction = false}) {
|
||||
return Story(
|
||||
id: id,
|
||||
cid: cid,
|
||||
owner: owner,
|
||||
settings: settings,
|
||||
time: time,
|
||||
updateTime: updateTime,
|
||||
expiration: expiration,
|
||||
media: media,
|
||||
reaction: clearReaction ? null : (reaction ?? this.reaction),
|
||||
);
|
||||
}
|
||||
|
||||
static Story? fromMap(Object? raw) {
|
||||
final map = _asStringMap(raw);
|
||||
final owner = StoryOwner.fromMap(map['owner']);
|
||||
if (owner == null) return null;
|
||||
return Story(
|
||||
id: parseIntOrNull(map['id']) ?? 0,
|
||||
cid: parseIntOrNull(map['cid']) ?? 0,
|
||||
owner: owner,
|
||||
settings: parseIntOrNull(map['settings']) ?? 0,
|
||||
time: parseIntOrNull(map['time']) ?? 0,
|
||||
updateTime: parseIntOrNull(map['updateTime']) ?? 0,
|
||||
expiration: parseIntOrNull(map['expiration']) ?? 0,
|
||||
media: StoryMedia.fromMap(map['media']),
|
||||
reaction: StoryReaction.fromMap(map['reaction']),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'cid': cid,
|
||||
'owner': owner.toMap(),
|
||||
'settings': settings,
|
||||
'time': time,
|
||||
'updateTime': updateTime,
|
||||
'expiration': expiration,
|
||||
if (media != null) 'media': media!.toJson(),
|
||||
if (reaction != null) 'reaction': reaction!.toMap(),
|
||||
};
|
||||
}
|
||||
|
||||
class StoryPreview {
|
||||
final StoryOwner owner;
|
||||
final int updateTime;
|
||||
final int totalCount;
|
||||
final int readCount;
|
||||
final int lastStoryExpirationTime;
|
||||
|
||||
const StoryPreview({
|
||||
required this.owner,
|
||||
this.updateTime = 0,
|
||||
this.totalCount = 0,
|
||||
this.readCount = 0,
|
||||
this.lastStoryExpirationTime = 0,
|
||||
});
|
||||
|
||||
int get unreadCount {
|
||||
final diff = totalCount - readCount;
|
||||
return diff < 0 ? 0 : diff;
|
||||
}
|
||||
|
||||
bool get hasUnread => unreadCount > 0;
|
||||
|
||||
bool get isEmpty => totalCount <= 0;
|
||||
|
||||
StoryPreview copyWith({int? readCount, int? totalCount}) => StoryPreview(
|
||||
owner: owner,
|
||||
updateTime: updateTime,
|
||||
totalCount: totalCount ?? this.totalCount,
|
||||
readCount: readCount ?? this.readCount,
|
||||
lastStoryExpirationTime: lastStoryExpirationTime,
|
||||
);
|
||||
|
||||
static StoryPreview? fromMap(Object? raw) {
|
||||
final map = _asStringMap(raw);
|
||||
final owner = StoryOwner.fromMap(map['owner']);
|
||||
if (owner == null) return null;
|
||||
return StoryPreview(
|
||||
owner: owner,
|
||||
updateTime: parseIntOrNull(map['updateTime']) ?? 0,
|
||||
totalCount: parseIntOrNull(map['totalCount']) ?? 0,
|
||||
readCount: parseIntOrNull(map['readCount']) ?? 0,
|
||||
lastStoryExpirationTime:
|
||||
parseIntOrNull(map['lastStoryExpirationTime']) ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'owner': owner.toMap(),
|
||||
'updateTime': updateTime,
|
||||
'totalCount': totalCount,
|
||||
'readCount': readCount,
|
||||
'lastStoryExpirationTime': lastStoryExpirationTime,
|
||||
};
|
||||
}
|
||||
|
||||
class PeerStories {
|
||||
final StoryOwner owner;
|
||||
final List<Story> stories;
|
||||
|
||||
const PeerStories({required this.owner, this.stories = const []});
|
||||
|
||||
static PeerStories? fromMap(Object? raw) {
|
||||
final map = _asStringMap(raw);
|
||||
final owner = StoryOwner.fromMap(map['owner']);
|
||||
if (owner == null) return null;
|
||||
final rawStories = map['stories'];
|
||||
final stories = <Story>[];
|
||||
if (rawStories is List) {
|
||||
for (final s in rawStories) {
|
||||
final story = Story.fromMap(s);
|
||||
if (story != null) stories.add(story);
|
||||
}
|
||||
}
|
||||
return PeerStories(owner: owner, stories: stories);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user