Line data Source code
1 : /*
2 : * Famedly Matrix SDK
3 : * Copyright (C) 2019, 2020, 2021 Famedly GmbH
4 : *
5 : * This program is free software: you can redistribute it and/or modify
6 : * it under the terms of the GNU Affero General Public License as
7 : * published by the Free Software Foundation, either version 3 of the
8 : * License, or (at your option) any later version.
9 : *
10 : * This program is distributed in the hope that it will be useful,
11 : * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 : * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 : * GNU Affero General Public License for more details.
14 : *
15 : * You should have received a copy of the GNU Affero General Public License
16 : * along with this program. If not, see <https://www.gnu.org/licenses/>.
17 : */
18 :
19 : import 'dart:convert';
20 : import 'dart:typed_data';
21 :
22 : import 'package:collection/collection.dart';
23 : import 'package:html/parser.dart';
24 : import 'package:mime/mime.dart';
25 :
26 : import 'package:matrix/matrix.dart';
27 : import 'package:matrix/src/utils/file_send_request_credentials.dart';
28 : import 'package:matrix/src/utils/html_to_text.dart';
29 : import 'package:matrix/src/utils/markdown.dart';
30 :
31 : abstract class RelationshipTypes {
32 : static const String reply = 'm.in_reply_to';
33 : static const String edit = 'm.replace';
34 : static const String reaction = 'm.annotation';
35 : static const String thread = 'm.thread';
36 : }
37 :
38 : /// All data exchanged over Matrix is expressed as an "event". Typically each client action (e.g. sending a message) correlates with exactly one event.
39 : class Event extends MatrixEvent {
40 : /// Requests the user object of the sender of this event.
41 12 : Future<User?> fetchSenderUser() => room.requestUser(
42 4 : senderId,
43 : ignoreErrors: true,
44 : );
45 :
46 0 : @Deprecated(
47 : 'Use eventSender instead or senderFromMemoryOrFallback for a synchronous alternative',
48 : )
49 0 : User get sender => senderFromMemoryOrFallback;
50 :
51 4 : User get senderFromMemoryOrFallback =>
52 12 : room.unsafeGetUserFromMemoryOrFallback(senderId);
53 :
54 : /// The room this event belongs to. May be null.
55 : final Room room;
56 :
57 : /// The status of this event.
58 : EventStatus status;
59 :
60 : static const EventStatus defaultStatus = EventStatus.synced;
61 :
62 : /// Optional. The event that redacted this event, if any. Otherwise null.
63 12 : Event? get redactedBecause {
64 21 : final redacted_because = unsigned?['redacted_because'];
65 12 : final room = this.room;
66 12 : return (redacted_because is Map<String, dynamic>)
67 5 : ? Event.fromJson(redacted_because, room)
68 : : null;
69 : }
70 :
71 24 : bool get redacted => redactedBecause != null;
72 :
73 4 : User? get stateKeyUser => stateKey != null
74 6 : ? room.unsafeGetUserFromMemoryOrFallback(stateKey!)
75 : : null;
76 :
77 : MatrixEvent? _originalSource;
78 :
79 76 : MatrixEvent? get originalSource => _originalSource;
80 :
81 113 : String? get transactionId => unsigned?.tryGet<String>('transaction_id');
82 :
83 40 : Event({
84 : this.status = defaultStatus,
85 : required Map<String, dynamic> super.content,
86 : required super.type,
87 : required String eventId,
88 : required super.senderId,
89 : required DateTime originServerTs,
90 : Map<String, dynamic>? unsigned,
91 : Map<String, dynamic>? prevContent,
92 : String? stateKey,
93 : super.redacts,
94 : required this.room,
95 : MatrixEvent? originalSource,
96 : }) : _originalSource = originalSource,
97 40 : super(
98 : eventId: eventId,
99 : originServerTs: originServerTs,
100 40 : roomId: room.id,
101 : ) {
102 40 : this.eventId = eventId;
103 40 : this.unsigned = unsigned;
104 : // synapse unfortunately isn't following the spec and tosses the prev_content
105 : // into the unsigned block.
106 : // Currently we are facing a very strange bug in web which is impossible to debug.
107 : // It may be because of this line so we put this in try-catch until we can fix it.
108 : try {
109 78 : this.prevContent = (prevContent != null && prevContent.isNotEmpty)
110 : ? prevContent
111 : : (unsigned != null &&
112 38 : unsigned.containsKey('prev_content') &&
113 6 : unsigned['prev_content'] is Map)
114 3 : ? unsigned['prev_content']
115 : : null;
116 : } catch (_) {
117 : // A strange bug in dart web makes this crash
118 : }
119 40 : this.stateKey = stateKey;
120 :
121 : // Mark event as failed to send if status is `sending` and event is older
122 : // than the timeout. This should not happen with the deprecated Moor
123 : // database!
124 80 : if (status.isSending) {
125 : // Age of this event in milliseconds
126 39 : final age = DateTime.now().millisecondsSinceEpoch -
127 13 : originServerTs.millisecondsSinceEpoch;
128 :
129 13 : final room = this.room;
130 :
131 : if (
132 : // We don't want to mark the event as failed if it's the lastEvent in the room
133 : // since that would be a race condition (with the same event from timeline)
134 : // The `room.lastEvent` is null at the time this constructor is called for it,
135 : // there's no other way to check this.
136 24 : room.lastEvent?.eventId != null &&
137 : // If the event is in the sending queue, then we don't mess with it.
138 33 : !room.sendingQueueEventsByTxId.contains(transactionId) &&
139 : // Else, if the event is older than the timeout, then we mark it as failed.
140 32 : age > room.client.sendTimelineEventTimeout.inMilliseconds) {
141 : // Update this event in database and open timelines
142 0 : final json = toJson();
143 0 : json['unsigned'] ??= <String, dynamic>{};
144 0 : json['unsigned'][messageSendingStatusKey] = EventStatus.error.intValue;
145 : // ignore: discarded_futures
146 0 : room.client.handleSync(
147 0 : SyncUpdate(
148 : nextBatch: '',
149 0 : rooms: RoomsUpdate(
150 0 : join: {
151 0 : room.id: JoinedRoomUpdate(
152 0 : timeline: TimelineUpdate(
153 0 : events: [MatrixEvent.fromJson(json)],
154 : ),
155 : ),
156 : },
157 : ),
158 : ),
159 : );
160 : }
161 : }
162 : }
163 :
164 38 : static Map<String, dynamic> getMapFromPayload(Object? payload) {
165 38 : if (payload is String) {
166 : try {
167 9 : return json.decode(payload);
168 : } catch (e) {
169 0 : return {};
170 : }
171 : }
172 38 : if (payload is Map<String, dynamic>) return payload;
173 38 : return {};
174 : }
175 :
176 40 : factory Event.fromMatrixEvent(
177 : MatrixEvent matrixEvent,
178 : Room room, {
179 : EventStatus? status,
180 : }) =>
181 40 : matrixEvent is Event
182 : ? matrixEvent
183 38 : : Event(
184 : status: status ??
185 38 : eventStatusFromInt(
186 38 : matrixEvent.unsigned
187 35 : ?.tryGet<int>(messageSendingStatusKey) ??
188 38 : defaultStatus.intValue,
189 : ),
190 38 : content: matrixEvent.content,
191 38 : type: matrixEvent.type,
192 38 : eventId: matrixEvent.eventId,
193 38 : senderId: matrixEvent.senderId,
194 38 : originServerTs: matrixEvent.originServerTs,
195 38 : unsigned: matrixEvent.unsigned,
196 38 : prevContent: matrixEvent.prevContent,
197 38 : stateKey: matrixEvent.stateKey,
198 38 : redacts: matrixEvent.redacts,
199 : room: room,
200 : );
201 :
202 : /// Get a State event from a table row or from the event stream.
203 38 : factory Event.fromJson(
204 : Map<String, dynamic> jsonPayload,
205 : Room room,
206 : ) {
207 76 : final content = Event.getMapFromPayload(jsonPayload['content']);
208 76 : final unsigned = Event.getMapFromPayload(jsonPayload['unsigned']);
209 76 : final prevContent = Event.getMapFromPayload(jsonPayload['prev_content']);
210 : final originalSource =
211 76 : Event.getMapFromPayload(jsonPayload['original_source']);
212 38 : return Event(
213 38 : status: eventStatusFromInt(
214 38 : jsonPayload['status'] ??
215 36 : unsigned[messageSendingStatusKey] ??
216 36 : defaultStatus.intValue,
217 : ),
218 38 : stateKey: jsonPayload['state_key'],
219 : prevContent: prevContent,
220 : content: content,
221 38 : type: jsonPayload['type'],
222 38 : eventId: jsonPayload['event_id'] ?? '',
223 38 : senderId: jsonPayload['sender'],
224 38 : originServerTs: DateTime.fromMillisecondsSinceEpoch(
225 38 : jsonPayload['origin_server_ts'] ?? 0,
226 : ),
227 : unsigned: unsigned,
228 : room: room,
229 38 : redacts: jsonPayload['redacts'],
230 : originalSource:
231 39 : originalSource.isEmpty ? null : MatrixEvent.fromJson(originalSource),
232 : );
233 : }
234 :
235 38 : @override
236 : Map<String, dynamic> toJson() {
237 38 : final data = <String, dynamic>{};
238 110 : if (stateKey != null) data['state_key'] = stateKey;
239 111 : if (prevContent?.isNotEmpty == true) {
240 70 : data['prev_content'] = prevContent;
241 : }
242 76 : data['content'] = content;
243 76 : data['type'] = type;
244 76 : data['event_id'] = eventId;
245 76 : data['room_id'] = roomId;
246 76 : data['sender'] = senderId;
247 114 : data['origin_server_ts'] = originServerTs.millisecondsSinceEpoch;
248 113 : if (unsigned?.isNotEmpty == true) {
249 74 : data['unsigned'] = unsigned;
250 : }
251 38 : if (originalSource != null) {
252 3 : data['original_source'] = originalSource?.toJson();
253 : }
254 38 : if (redacts != null) {
255 10 : data['redacts'] = redacts;
256 : }
257 114 : data['status'] = status.intValue;
258 : return data;
259 : }
260 :
261 70 : User get asUser => User.fromState(
262 : // state key should always be set for member events
263 35 : stateKey: stateKey!,
264 35 : prevContent: prevContent,
265 35 : content: content,
266 35 : typeKey: type,
267 35 : senderId: senderId,
268 35 : room: room,
269 35 : originServerTs: originServerTs,
270 : );
271 :
272 24 : String get messageType => type == EventTypes.Sticker
273 : ? MessageTypes.Sticker
274 16 : : (content.tryGet<String>('msgtype') ?? MessageTypes.Text);
275 :
276 5 : void setRedactionEvent(Event redactedBecause) {
277 10 : unsigned = {
278 5 : 'redacted_because': redactedBecause.toJson(),
279 : };
280 5 : prevContent = null;
281 5 : _originalSource = null;
282 5 : final contentKeyWhiteList = <String>[];
283 5 : switch (type) {
284 5 : case EventTypes.RoomMember:
285 2 : contentKeyWhiteList.add('membership');
286 : break;
287 5 : case EventTypes.RoomCreate:
288 2 : contentKeyWhiteList.add('creator');
289 : break;
290 5 : case EventTypes.RoomJoinRules:
291 2 : contentKeyWhiteList.add('join_rule');
292 : break;
293 5 : case EventTypes.RoomPowerLevels:
294 2 : contentKeyWhiteList.add('ban');
295 2 : contentKeyWhiteList.add('events');
296 2 : contentKeyWhiteList.add('events_default');
297 2 : contentKeyWhiteList.add('kick');
298 2 : contentKeyWhiteList.add('redact');
299 2 : contentKeyWhiteList.add('state_default');
300 2 : contentKeyWhiteList.add('users');
301 2 : contentKeyWhiteList.add('users_default');
302 : break;
303 5 : case EventTypes.RoomAliases:
304 2 : contentKeyWhiteList.add('aliases');
305 : break;
306 5 : case EventTypes.HistoryVisibility:
307 2 : contentKeyWhiteList.add('history_visibility');
308 : break;
309 : default:
310 : break;
311 : }
312 20 : content.removeWhere((k, v) => !contentKeyWhiteList.contains(k));
313 : }
314 :
315 : /// Returns the body of this event if it has a body.
316 30 : String get text => content.tryGet<String>('body') ?? '';
317 :
318 : /// Returns the formatted boy of this event if it has a formatted body.
319 15 : String get formattedText => content.tryGet<String>('formatted_body') ?? '';
320 :
321 : /// Use this to get the body.
322 10 : String get body {
323 10 : if (redacted) return 'Redacted';
324 30 : if (text != '') return text;
325 2 : return type;
326 : }
327 :
328 : /// Use this to get a plain-text representation of the event, stripping things
329 : /// like spoilers and thelike. Useful for plain text notifications.
330 4 : String get plaintextBody => switch (formattedText) {
331 : // if the formattedText is empty, fallback to body
332 4 : '' => body,
333 8 : final String s when content['format'] == 'org.matrix.custom.html' =>
334 2 : HtmlToText.convert(s),
335 2 : _ => body,
336 : };
337 :
338 : /// Returns a list of [Receipt] instances for this event.
339 3 : List<Receipt> get receipts {
340 3 : final room = this.room;
341 3 : final receipts = room.receiptState;
342 9 : final receiptsList = receipts.global.otherUsers.entries
343 8 : .where((entry) => entry.value.eventId == eventId)
344 3 : .map(
345 2 : (entry) => Receipt(
346 2 : room.unsafeGetUserFromMemoryOrFallback(entry.key),
347 2 : entry.value.timestamp,
348 : ),
349 : )
350 3 : .toList();
351 :
352 : // add your own only once
353 6 : final own = receipts.global.latestOwnReceipt ??
354 3 : receipts.mainThread?.latestOwnReceipt;
355 3 : if (own != null && own.eventId == eventId) {
356 1 : receiptsList.add(
357 1 : Receipt(
358 3 : room.unsafeGetUserFromMemoryOrFallback(room.client.userID!),
359 1 : own.timestamp,
360 : ),
361 : );
362 : }
363 :
364 : // also add main thread. https://github.com/famedly/product-management/issues/1020
365 : // also deduplicate.
366 3 : receiptsList.addAll(
367 5 : receipts.mainThread?.otherUsers.entries
368 1 : .where(
369 1 : (entry) =>
370 4 : entry.value.eventId == eventId &&
371 : receiptsList
372 6 : .every((element) => element.user.id != entry.key),
373 : )
374 1 : .map(
375 2 : (entry) => Receipt(
376 2 : room.unsafeGetUserFromMemoryOrFallback(entry.key),
377 2 : entry.value.timestamp,
378 : ),
379 : ) ??
380 3 : [],
381 : );
382 :
383 : return receiptsList;
384 : }
385 :
386 0 : @Deprecated('Use [cancelSend()] instead.')
387 : Future<bool> remove() async {
388 : try {
389 0 : await cancelSend();
390 : return true;
391 : } catch (_) {
392 : return false;
393 : }
394 : }
395 :
396 : /// Removes an unsent or yet-to-send event from the database and timeline.
397 : /// These are events marked with the status `SENDING` or `ERROR`.
398 : /// Throws an exception if used for an already sent event!
399 : ///
400 6 : Future<void> cancelSend() async {
401 12 : if (status.isSent) {
402 2 : throw Exception('Can only delete events which are not sent yet!');
403 : }
404 :
405 42 : await room.client.database.removeEvent(eventId, room.id);
406 :
407 22 : if (room.lastEvent != null && room.lastEvent!.eventId == eventId) {
408 2 : final redactedBecause = Event.fromMatrixEvent(
409 2 : MatrixEvent(
410 : type: EventTypes.Redaction,
411 4 : content: {'redacts': eventId},
412 2 : redacts: eventId,
413 2 : senderId: senderId,
414 4 : eventId: '${eventId}_cancel_send',
415 2 : originServerTs: DateTime.now(),
416 : ),
417 2 : room,
418 : );
419 :
420 6 : await room.client.handleSync(
421 2 : SyncUpdate(
422 : nextBatch: '',
423 2 : rooms: RoomsUpdate(
424 2 : join: {
425 6 : room.id: JoinedRoomUpdate(
426 2 : timeline: TimelineUpdate(
427 2 : events: [redactedBecause],
428 : ),
429 : ),
430 : },
431 : ),
432 : ),
433 : );
434 : }
435 30 : room.client.onCancelSendEvent.add(eventId);
436 : }
437 :
438 : /// Try to send this event again. Only works with events of status -1.
439 4 : Future<String?> sendAgain({String? txid}) async {
440 8 : if (!status.isError) return null;
441 :
442 : // Retry sending a file:
443 : if ({
444 4 : MessageTypes.Image,
445 4 : MessageTypes.Video,
446 4 : MessageTypes.Audio,
447 4 : MessageTypes.File,
448 8 : }.contains(messageType)) {
449 0 : final file = room.sendingFilePlaceholders[eventId];
450 : if (file == null) {
451 0 : await cancelSend();
452 0 : throw Exception('Can not try to send again. File is no longer cached.');
453 : }
454 0 : final thumbnail = room.sendingFileThumbnails[eventId];
455 0 : final credentials = FileSendRequestCredentials.fromJson(unsigned ?? {});
456 0 : final inReplyTo = credentials.inReplyTo == null
457 : ? null
458 0 : : await room.getEventById(credentials.inReplyTo!);
459 0 : return await room.sendFileEvent(
460 : file,
461 0 : txid: txid ?? transactionId,
462 : thumbnail: thumbnail,
463 : inReplyTo: inReplyTo,
464 0 : editEventId: credentials.editEventId,
465 0 : shrinkImageMaxDimension: credentials.shrinkImageMaxDimension,
466 0 : extraContent: credentials.extraContent,
467 : );
468 : }
469 :
470 : // we do not remove the event here. It will automatically be updated
471 : // in the `sendEvent` method to transition -1 -> 0 -> 1 -> 2
472 8 : return await room.sendEvent(
473 4 : content,
474 2 : txid: txid ?? transactionId ?? eventId,
475 : );
476 : }
477 :
478 : /// Whether the client is allowed to redact this event.
479 12 : bool get canRedact => senderId == room.client.userID || room.canRedact;
480 :
481 : /// Redacts this event. Throws `ErrorResponse` on error.
482 1 : Future<String?> redactEvent({String? reason, String? txid}) async =>
483 3 : await room.redactEvent(eventId, reason: reason, txid: txid);
484 :
485 : /// Searches for the reply event in the given timeline. Also returns the
486 : /// event fallback if the relationship type is `m.thread`.
487 : /// https://spec.matrix.org/v1.14/client-server-api/#fallback-for-unthreaded-clients
488 2 : Future<Event?> getReplyEvent(Timeline timeline) async {
489 2 : switch (relationshipType) {
490 2 : case RelationshipTypes.reply:
491 0 : final relationshipEventId = this.relationshipEventId;
492 : return relationshipEventId == null
493 : ? null
494 0 : : await timeline.getEventById(relationshipEventId);
495 :
496 2 : case RelationshipTypes.thread:
497 : final relationshipContent =
498 4 : content.tryGetMap<String, Object?>('m.relates_to');
499 : if (relationshipContent == null) return null;
500 : final String? relationshipEventId;
501 4 : if (relationshipContent.tryGet<bool>('is_falling_back') == true) {
502 : relationshipEventId = relationshipContent
503 2 : .tryGetMap<String, Object?>('m.in_reply_to')
504 2 : ?.tryGet<String>('event_id');
505 : } else {
506 0 : relationshipEventId = this.relationshipEventId;
507 : }
508 : return relationshipEventId == null
509 : ? null
510 2 : : await timeline.getEventById(relationshipEventId);
511 : default:
512 : return null;
513 : }
514 : }
515 :
516 : /// If this event is encrypted and the decryption was not successful because
517 : /// the session is unknown, this requests the session key from other devices
518 : /// in the room. If the event is not encrypted or the decryption failed because
519 : /// of a different error, this throws an exception.
520 1 : Future<void> requestKey() async {
521 2 : if (type != EventTypes.Encrypted ||
522 2 : messageType != MessageTypes.BadEncrypted ||
523 3 : content['can_request_session'] != true) {
524 : throw ('Session key not requestable');
525 : }
526 :
527 2 : final sessionId = content.tryGet<String>('session_id');
528 2 : final senderKey = content.tryGet<String>('sender_key');
529 : if (sessionId == null || senderKey == null) {
530 : throw ('Unknown session_id or sender_key');
531 : }
532 2 : await room.requestSessionKey(sessionId, senderKey);
533 : return;
534 : }
535 :
536 : /// Gets the info map of file events, or a blank map if none present
537 2 : Map<String, Object?> get infoMap =>
538 6 : content.tryGetMap<String, Object?>('info') ?? <String, Object?>{};
539 :
540 : /// Gets the thumbnail info map of file events, or a blank map if nonepresent
541 8 : Map<String, Object?> get thumbnailInfoMap => infoMap['thumbnail_info'] is Map
542 6 : ? (infoMap['thumbnail_info'] as Map).cast<String, Object?>()
543 1 : : <String, Object?>{};
544 :
545 : /// Returns if a file event has an attachment
546 11 : bool get hasAttachment => content['url'] is String || content['file'] is Map;
547 :
548 : /// Returns if a file event has a thumbnail
549 2 : bool get hasThumbnail =>
550 12 : infoMap['thumbnail_url'] is String || infoMap['thumbnail_file'] is Map;
551 :
552 : /// Returns if a file events attachment is encrypted
553 8 : bool get isAttachmentEncrypted => content['file'] is Map;
554 :
555 : /// Returns if a file events thumbnail is encrypted
556 8 : bool get isThumbnailEncrypted => infoMap['thumbnail_file'] is Map;
557 :
558 : /// Gets the mimetype of the attachment of a file event, or a blank string if not present
559 2 : String get attachmentMimetype =>
560 6 : infoMap.tryGet<String>('mimetype')?.toLowerCase() ??
561 5 : (content.tryGetMap<String, Object?>('file')?.tryGet<String>('mimetype') ??
562 : '');
563 :
564 : /// Gets the mimetype of the thumbnail of a file event, or a blank string if not present
565 2 : String get thumbnailMimetype =>
566 6 : thumbnailInfoMap.tryGet<String>('mimetype')?.toLowerCase() ??
567 1 : (infoMap
568 1 : .tryGetMap<String, Object?>('thumbnail_file')
569 1 : ?.tryGet<String>('mimetype') ??
570 : '');
571 :
572 : /// Gets the underlying mxc url of an attachment of a file event, or null if not present
573 2 : Uri? get attachmentMxcUrl {
574 2 : final url = isAttachmentEncrypted
575 3 : ? (content.tryGetMap<String, Object?>('file')?['url'])
576 4 : : content['url'];
577 4 : return url is String ? Uri.tryParse(url) : null;
578 : }
579 :
580 : /// Gets the underlying mxc url of a thumbnail of a file event, or null if not present
581 2 : Uri? get thumbnailMxcUrl {
582 2 : final url = isThumbnailEncrypted
583 3 : ? (infoMap['thumbnail_file'] as Map)['url']
584 4 : : infoMap['thumbnail_url'];
585 4 : return url is String ? Uri.tryParse(url) : null;
586 : }
587 :
588 : /// Gets the mxc url of an attachment/thumbnail of a file event, taking sizes into account, or null if not present
589 2 : Uri? attachmentOrThumbnailMxcUrl({bool getThumbnail = false}) {
590 : if (getThumbnail &&
591 6 : infoMap['size'] is int &&
592 6 : thumbnailInfoMap['size'] is int &&
593 0 : (infoMap['size'] as int) <= (thumbnailInfoMap['size'] as int)) {
594 : getThumbnail = false;
595 : }
596 2 : if (getThumbnail && !hasThumbnail) {
597 : getThumbnail = false;
598 : }
599 4 : return getThumbnail ? thumbnailMxcUrl : attachmentMxcUrl;
600 : }
601 :
602 : // size determined from an approximate 800x800 jpeg thumbnail with method=scale
603 : static const _minNoThumbSize = 80 * 1024;
604 :
605 : /// Gets the attachment https URL to display in the timeline, taking into account if the original image is tiny.
606 : /// Returns null for encrypted rooms, if the image can't be fetched via http url or if the event does not contain an attachment.
607 : /// Set [getThumbnail] to true to fetch the thumbnail, set [width], [height] and [method]
608 : /// for the respective thumbnailing properties.
609 : /// [minNoThumbSize] is the minimum size that an original image may be to not fetch its thumbnail, defaults to 80k
610 : /// [useThumbnailMxcUrl] says weather to use the mxc url of the thumbnail, rather than the original attachment.
611 : /// [animated] says weather the thumbnail is animated
612 : ///
613 : /// Throws an exception if the scheme is not `mxc` or the homeserver is not
614 : /// set.
615 : ///
616 : /// Important! To use this link you have to set a http header like this:
617 : /// `headers: {"authorization": "Bearer ${client.accessToken}"}`
618 2 : Future<Uri?> getAttachmentUri({
619 : bool getThumbnail = false,
620 : bool useThumbnailMxcUrl = false,
621 : double width = 800.0,
622 : double height = 800.0,
623 : ThumbnailMethod method = ThumbnailMethod.scale,
624 : int minNoThumbSize = _minNoThumbSize,
625 : bool animated = false,
626 : }) async {
627 6 : if (![EventTypes.Message, EventTypes.Sticker].contains(type) ||
628 2 : !hasAttachment ||
629 2 : isAttachmentEncrypted) {
630 : return null; // can't url-thumbnail in encrypted rooms
631 : }
632 2 : if (useThumbnailMxcUrl && !hasThumbnail) {
633 : return null; // can't fetch from thumbnail
634 : }
635 4 : final thisInfoMap = useThumbnailMxcUrl ? thumbnailInfoMap : infoMap;
636 : final thisMxcUrl =
637 8 : useThumbnailMxcUrl ? infoMap['thumbnail_url'] : content['url'];
638 : // if we have as method scale, we can return safely the original image, should it be small enough
639 : if (getThumbnail &&
640 2 : method == ThumbnailMethod.scale &&
641 4 : thisInfoMap['size'] is int &&
642 4 : (thisInfoMap['size'] as int) < minNoThumbSize) {
643 : getThumbnail = false;
644 : }
645 : // now generate the actual URLs
646 : if (getThumbnail) {
647 4 : return await Uri.parse(thisMxcUrl as String).getThumbnailUri(
648 4 : room.client,
649 : width: width,
650 : height: height,
651 : method: method,
652 : animated: animated,
653 : );
654 : } else {
655 8 : return await Uri.parse(thisMxcUrl as String).getDownloadUri(room.client);
656 : }
657 : }
658 :
659 : /// Gets the attachment https URL to display in the timeline, taking into account if the original image is tiny.
660 : /// Returns null for encrypted rooms, if the image can't be fetched via http url or if the event does not contain an attachment.
661 : /// Set [getThumbnail] to true to fetch the thumbnail, set [width], [height] and [method]
662 : /// for the respective thumbnailing properties.
663 : /// [minNoThumbSize] is the minimum size that an original image may be to not fetch its thumbnail, defaults to 80k
664 : /// [useThumbnailMxcUrl] says weather to use the mxc url of the thumbnail, rather than the original attachment.
665 : /// [animated] says weather the thumbnail is animated
666 : ///
667 : /// Throws an exception if the scheme is not `mxc` or the homeserver is not
668 : /// set.
669 : ///
670 : /// Important! To use this link you have to set a http header like this:
671 : /// `headers: {"authorization": "Bearer ${client.accessToken}"}`
672 0 : @Deprecated('Use getAttachmentUri() instead')
673 : Uri? getAttachmentUrl({
674 : bool getThumbnail = false,
675 : bool useThumbnailMxcUrl = false,
676 : double width = 800.0,
677 : double height = 800.0,
678 : ThumbnailMethod method = ThumbnailMethod.scale,
679 : int minNoThumbSize = _minNoThumbSize,
680 : bool animated = false,
681 : }) {
682 0 : if (![EventTypes.Message, EventTypes.Sticker].contains(type) ||
683 0 : !hasAttachment ||
684 0 : isAttachmentEncrypted) {
685 : return null; // can't url-thumbnail in encrypted rooms
686 : }
687 0 : if (useThumbnailMxcUrl && !hasThumbnail) {
688 : return null; // can't fetch from thumbnail
689 : }
690 0 : final thisInfoMap = useThumbnailMxcUrl ? thumbnailInfoMap : infoMap;
691 : final thisMxcUrl =
692 0 : useThumbnailMxcUrl ? infoMap['thumbnail_url'] : content['url'];
693 : // if we have as method scale, we can return safely the original image, should it be small enough
694 : if (getThumbnail &&
695 0 : method == ThumbnailMethod.scale &&
696 0 : thisInfoMap['size'] is int &&
697 0 : (thisInfoMap['size'] as int) < minNoThumbSize) {
698 : getThumbnail = false;
699 : }
700 : // now generate the actual URLs
701 : if (getThumbnail) {
702 0 : return Uri.parse(thisMxcUrl as String).getThumbnail(
703 0 : room.client,
704 : width: width,
705 : height: height,
706 : method: method,
707 : animated: animated,
708 : );
709 : } else {
710 0 : return Uri.parse(thisMxcUrl as String).getDownloadLink(room.client);
711 : }
712 : }
713 :
714 : /// Returns if an attachment is in the local store
715 1 : Future<bool> isAttachmentInLocalStore({bool getThumbnail = false}) async {
716 3 : if (![EventTypes.Message, EventTypes.Sticker].contains(type)) {
717 0 : throw ("This event has the type '$type' and so it can't contain an attachment.");
718 : }
719 1 : final mxcUrl = attachmentOrThumbnailMxcUrl(getThumbnail: getThumbnail);
720 : if (mxcUrl == null) {
721 : throw "This event hasn't any attachment or thumbnail.";
722 : }
723 2 : getThumbnail = mxcUrl != attachmentMxcUrl;
724 : // Is this file storeable?
725 1 : final thisInfoMap = getThumbnail ? thumbnailInfoMap : infoMap;
726 3 : final database = room.client.database;
727 :
728 2 : final storeable = thisInfoMap['size'] is int &&
729 3 : (thisInfoMap['size'] as int) <= database.maxFileSize;
730 :
731 : Uint8List? uint8list;
732 : if (storeable) {
733 0 : uint8list = await database.getFile(mxcUrl);
734 : }
735 : return uint8list != null;
736 : }
737 :
738 : /// Downloads (and decrypts if necessary) the attachment of this
739 : /// event and returns it as a [MatrixFile]. If this event doesn't
740 : /// contain an attachment, this throws an error. Set [getThumbnail] to
741 : /// true to download the thumbnail instead. Set [fromLocalStoreOnly] to true
742 : /// if you want to retrieve the attachment from the local store only without
743 : /// making http request.
744 2 : Future<MatrixFile> downloadAndDecryptAttachment({
745 : bool getThumbnail = false,
746 : Future<Uint8List> Function(Uri)? downloadCallback,
747 : bool fromLocalStoreOnly = false,
748 : }) async {
749 6 : if (![EventTypes.Message, EventTypes.Sticker].contains(type)) {
750 0 : throw ("This event has the type '$type' and so it can't contain an attachment.");
751 : }
752 4 : if (status.isSending) {
753 0 : final localFile = room.sendingFilePlaceholders[eventId];
754 : if (localFile != null) return localFile;
755 : }
756 6 : final database = room.client.database;
757 2 : final mxcUrl = attachmentOrThumbnailMxcUrl(getThumbnail: getThumbnail);
758 : if (mxcUrl == null) {
759 : throw "This event hasn't any attachment or thumbnail.";
760 : }
761 4 : getThumbnail = mxcUrl != attachmentMxcUrl;
762 : final isEncrypted =
763 4 : getThumbnail ? isThumbnailEncrypted : isAttachmentEncrypted;
764 3 : if (isEncrypted && !room.client.encryptionEnabled) {
765 : throw ('Encryption is not enabled in your Client.');
766 : }
767 :
768 : // Is this file storeable?
769 4 : final thisInfoMap = getThumbnail ? thumbnailInfoMap : infoMap;
770 4 : var storeable = thisInfoMap['size'] is int &&
771 6 : (thisInfoMap['size'] as int) <= database.maxFileSize;
772 :
773 : Uint8List? uint8list;
774 : if (storeable) {
775 0 : uint8list = await room.client.database.getFile(mxcUrl);
776 : }
777 :
778 : // Download the file
779 : final canDownloadFileFromServer = uint8list == null && !fromLocalStoreOnly;
780 : if (canDownloadFileFromServer) {
781 6 : final httpClient = room.client.httpClient;
782 0 : downloadCallback ??= (Uri url) async => (await httpClient.get(
783 : url,
784 0 : headers: {'authorization': 'Bearer ${room.client.accessToken}'},
785 : ))
786 0 : .bodyBytes;
787 : uint8list =
788 8 : await downloadCallback(await mxcUrl.getDownloadUri(room.client));
789 0 : storeable = storeable && uint8list.lengthInBytes < database.maxFileSize;
790 : if (storeable) {
791 0 : await database.storeFile(
792 : mxcUrl,
793 : uint8list,
794 0 : DateTime.now().millisecondsSinceEpoch,
795 : );
796 : }
797 : } else if (uint8list == null) {
798 : throw ('Unable to download file from local store.');
799 : }
800 :
801 : // Decrypt the file
802 : if (isEncrypted) {
803 : final fileMap = getThumbnail
804 2 : ? infoMap['thumbnail_file'] as Map
805 2 : : content['file'] as Map;
806 3 : if (!(fileMap['key'] as Map)['key_ops'].contains('decrypt')) {
807 : throw ("Missing 'decrypt' in 'key_ops'.");
808 : }
809 1 : final encryptedFile = EncryptedFile(
810 : data: uint8list,
811 1 : iv: fileMap['iv'] as String,
812 2 : k: (fileMap['key'] as Map)['k'] as String,
813 2 : sha256: (fileMap['hashes'] as Map)['sha256'] as String,
814 : );
815 : uint8list =
816 4 : await room.client.nativeImplementations.decryptFile(encryptedFile);
817 : if (uint8list == null) {
818 : throw ('Unable to decrypt file');
819 : }
820 : }
821 :
822 6 : final filename = content.tryGet<String>('filename') ?? body;
823 2 : final mimeType = attachmentMimetype;
824 :
825 2 : return MatrixFile(
826 : bytes: uint8list,
827 : name: getThumbnail
828 2 : ? '$filename.thumbnail.${extensionFromMime(mimeType)}'
829 2 : : filename,
830 2 : mimeType: attachmentMimetype,
831 : );
832 : }
833 :
834 : /// Returns if this is a known event type.
835 2 : bool get isEventTypeKnown =>
836 6 : EventLocalizations.localizationsMap.containsKey(type);
837 :
838 : /// Returns a localized String representation of this event. For a
839 : /// room list you may find [withSenderNamePrefix] useful. Set [hideReply] to
840 : /// crop all lines starting with '>'. With [plaintextBody] it'll use the
841 : /// plaintextBody instead of the normal body which in practice will convert
842 : /// the html body to a plain text body before falling back to the body. In
843 : /// either case this function won't return the html body without converting
844 : /// it to plain text.
845 : /// [removeMarkdown] allow to remove the markdown formating from the event body.
846 : /// Usefull form message preview or notifications text.
847 4 : Future<String> calcLocalizedBody(
848 : MatrixLocalizations i18n, {
849 : bool withSenderNamePrefix = false,
850 : bool hideReply = false,
851 : bool hideEdit = false,
852 : bool plaintextBody = false,
853 : bool removeMarkdown = false,
854 : }) async {
855 4 : if (redacted) {
856 8 : await redactedBecause?.fetchSenderUser();
857 : }
858 :
859 : if (withSenderNamePrefix &&
860 4 : (type == EventTypes.Message || type.contains(EventTypes.Encrypted))) {
861 : // To be sure that if the event need to be localized, the user is in memory.
862 : // used by EventLocalizations._localizedBodyNormalMessage
863 2 : await fetchSenderUser();
864 : }
865 :
866 4 : return calcLocalizedBodyFallback(
867 : i18n,
868 : withSenderNamePrefix: withSenderNamePrefix,
869 : hideReply: hideReply,
870 : hideEdit: hideEdit,
871 : plaintextBody: plaintextBody,
872 : removeMarkdown: removeMarkdown,
873 : );
874 : }
875 :
876 0 : @Deprecated('Use calcLocalizedBody or calcLocalizedBodyFallback')
877 : String getLocalizedBody(
878 : MatrixLocalizations i18n, {
879 : bool withSenderNamePrefix = false,
880 : bool hideReply = false,
881 : bool hideEdit = false,
882 : bool plaintextBody = false,
883 : bool removeMarkdown = false,
884 : }) =>
885 0 : calcLocalizedBodyFallback(
886 : i18n,
887 : withSenderNamePrefix: withSenderNamePrefix,
888 : hideReply: hideReply,
889 : hideEdit: hideEdit,
890 : plaintextBody: plaintextBody,
891 : removeMarkdown: removeMarkdown,
892 : );
893 :
894 : /// Works similar to `calcLocalizedBody()` but does not wait for the sender
895 : /// user to be fetched. If it is not in the cache it will just use the
896 : /// fallback and display the localpart of the MXID according to the
897 : /// values of `formatLocalpart` and `mxidLocalPartFallback` in the `Client`
898 : /// class.
899 4 : String calcLocalizedBodyFallback(
900 : MatrixLocalizations i18n, {
901 : bool withSenderNamePrefix = false,
902 : bool hideReply = false,
903 : bool hideEdit = false,
904 : bool plaintextBody = false,
905 : bool removeMarkdown = false,
906 : }) {
907 4 : if (redacted) {
908 16 : if (status.intValue < EventStatus.synced.intValue) {
909 2 : return i18n.cancelledSend;
910 : }
911 2 : return i18n.removedBy(this);
912 : }
913 :
914 2 : final body = calcUnlocalizedBody(
915 : hideReply: hideReply,
916 : hideEdit: hideEdit,
917 : plaintextBody: plaintextBody,
918 : removeMarkdown: removeMarkdown,
919 : );
920 :
921 6 : final callback = EventLocalizations.localizationsMap[type];
922 4 : var localizedBody = i18n.unknownEvent(type);
923 : if (callback != null) {
924 2 : localizedBody = callback(this, i18n, body);
925 : }
926 :
927 : // Add the sender name prefix
928 : if (withSenderNamePrefix &&
929 4 : type == EventTypes.Message &&
930 4 : textOnlyMessageTypes.contains(messageType)) {
931 10 : final senderNameOrYou = senderId == room.client.userID
932 0 : ? i18n.you
933 4 : : senderFromMemoryOrFallback.calcDisplayname(i18n: i18n);
934 2 : localizedBody = '$senderNameOrYou: $localizedBody';
935 : }
936 :
937 : return localizedBody;
938 : }
939 :
940 : /// Calculating the body of an event regardless of localization.
941 2 : String calcUnlocalizedBody({
942 : bool hideReply = false,
943 : bool hideEdit = false,
944 : bool plaintextBody = false,
945 : bool removeMarkdown = false,
946 : }) {
947 2 : if (redacted) {
948 0 : return 'Removed by ${senderFromMemoryOrFallback.displayName ?? senderId}';
949 : }
950 4 : var body = plaintextBody ? this.plaintextBody : this.body;
951 :
952 : // Html messages will already have their reply fallback removed during the Html to Text conversion.
953 : var mayHaveReplyFallback = !plaintextBody ||
954 6 : (content['format'] != 'org.matrix.custom.html' ||
955 4 : formattedText.isEmpty);
956 :
957 : // If we have an edit, we want to operate on the new content
958 4 : final newContent = content.tryGetMap<String, Object?>('m.new_content');
959 : if (hideEdit &&
960 4 : relationshipType == RelationshipTypes.edit &&
961 : newContent != null) {
962 : final newBody =
963 2 : newContent.tryGet<String>('formatted_body', TryGet.silent);
964 : if (plaintextBody &&
965 4 : newContent['format'] == 'org.matrix.custom.html' &&
966 : newBody != null &&
967 2 : newBody.isNotEmpty) {
968 : mayHaveReplyFallback = false;
969 2 : body = HtmlToText.convert(newBody);
970 : } else {
971 : mayHaveReplyFallback = true;
972 2 : body = newContent.tryGet<String>('body') ?? body;
973 : }
974 : }
975 : // Hide reply fallback
976 : // Be sure that the plaintextBody already stripped teh reply fallback,
977 : // if the message is formatted
978 : if (hideReply && mayHaveReplyFallback) {
979 2 : body = body.replaceFirst(
980 2 : RegExp(r'^>( \*)? <[^>]+>[^\n\r]+\r?\n(> [^\n]*\r?\n)*\r?\n'),
981 : '',
982 : );
983 : }
984 :
985 : // return the html tags free body
986 2 : if (removeMarkdown == true) {
987 2 : final html = markdown(body, convertLinebreaks: false);
988 2 : final document = parse(html);
989 6 : body = document.documentElement?.text.trim() ?? body;
990 : }
991 : return body;
992 : }
993 :
994 : static const Set<String> textOnlyMessageTypes = {
995 : MessageTypes.Text,
996 : MessageTypes.Notice,
997 : MessageTypes.Emote,
998 : MessageTypes.None,
999 : };
1000 :
1001 : /// returns if this event matches the passed event or transaction id
1002 4 : bool matchesEventOrTransactionId(String? search) {
1003 : if (search == null) {
1004 : return false;
1005 : }
1006 8 : if (eventId == search) {
1007 : return true;
1008 : }
1009 8 : return transactionId == search;
1010 : }
1011 :
1012 : /// Get the relationship type of an event. `null` if there is none
1013 37 : String? get relationshipType {
1014 74 : final mRelatesTo = content.tryGetMap<String, Object?>('m.relates_to');
1015 : if (mRelatesTo == null) {
1016 : return null;
1017 : }
1018 8 : final relType = mRelatesTo.tryGet<String>('rel_type');
1019 8 : if (relType == RelationshipTypes.thread) {
1020 : return RelationshipTypes.thread;
1021 : }
1022 :
1023 8 : if (mRelatesTo.containsKey('m.in_reply_to')) {
1024 : return RelationshipTypes.reply;
1025 : }
1026 : return relType;
1027 : }
1028 :
1029 : /// Get the event ID that this relationship will reference. `null` if there is none
1030 37 : String? get relationshipEventId {
1031 74 : final relatesToMap = content.tryGetMap<String, Object?>('m.relates_to');
1032 8 : return relatesToMap?.tryGet<String>('event_id') ??
1033 : relatesToMap
1034 4 : ?.tryGetMap<String, Object?>('m.in_reply_to')
1035 4 : ?.tryGet<String>('event_id');
1036 : }
1037 :
1038 : /// Get whether this event has aggregated events from a certain [type]
1039 : /// To be able to do that you need to pass a [timeline]
1040 3 : bool hasAggregatedEvents(Timeline timeline, String type) =>
1041 15 : timeline.aggregatedEvents[eventId]?.containsKey(type) == true;
1042 :
1043 : /// Get all the aggregated event objects for a given [type]. To be able to do this
1044 : /// you have to pass a [timeline]
1045 3 : Set<Event> aggregatedEvents(Timeline timeline, String type) =>
1046 12 : timeline.aggregatedEvents[eventId]?[type] ?? <Event>{};
1047 :
1048 : /// Fetches the event to be rendered, taking into account all the edits and the like.
1049 : /// It needs a [timeline] for that.
1050 3 : Event getDisplayEvent(Timeline timeline) {
1051 3 : if (redacted) {
1052 : return this;
1053 : }
1054 3 : if (hasAggregatedEvents(timeline, RelationshipTypes.edit)) {
1055 : // alright, we have an edit
1056 3 : final allEditEvents = aggregatedEvents(timeline, RelationshipTypes.edit)
1057 : // we only allow edits made by the original author themself
1058 21 : .where((e) => e.senderId == senderId && e.type == EventTypes.Message)
1059 3 : .toList();
1060 : // we need to check again if it isn't empty, as we potentially removed all
1061 : // aggregated edits
1062 3 : if (allEditEvents.isNotEmpty) {
1063 3 : allEditEvents.sort(
1064 8 : (a, b) => a.originServerTs.millisecondsSinceEpoch -
1065 6 : b.originServerTs.millisecondsSinceEpoch >
1066 : 0
1067 : ? 1
1068 2 : : -1,
1069 : );
1070 6 : final rawEvent = allEditEvents.last.toJson();
1071 : // update the content of the new event to render
1072 9 : if (rawEvent['content']['m.new_content'] is Map) {
1073 9 : rawEvent['content'] = rawEvent['content']['m.new_content'];
1074 : }
1075 6 : return Event.fromJson(rawEvent, room);
1076 : }
1077 : }
1078 : return this;
1079 : }
1080 :
1081 : /// returns if a message is a rich message
1082 2 : bool get isRichMessage =>
1083 6 : content['format'] == 'org.matrix.custom.html' &&
1084 6 : content['formatted_body'] is String;
1085 :
1086 : // regexes to fetch the number of emotes, including emoji, and if the message consists of only those
1087 : // to match an emoji we can use the following regularly updated regex : https://stackoverflow.com/a/67705964
1088 : // to see if there is a custom emote, we use the following regex: <img[^>]+data-mx-(?:emote|emoticon)(?==|>|\s)[^>]*>
1089 : // now we combined the two to have four regexes and one helper:
1090 : // 0. the raw components
1091 : // - the pure unicode sequence from the link above and
1092 : // - the padded sequence with whitespace, option selection and copyright/tm sign
1093 : // - the matrix emoticon sequence
1094 : // 1. are there only emoji, or whitespace
1095 : // 2. are there only emoji, emotes, or whitespace
1096 : // 3. count number of emoji
1097 : // 4. count number of emoji or emotes
1098 :
1099 : // update from : https://stackoverflow.com/a/67705964
1100 : static const _unicodeSequences =
1101 : r'\u00a9|\u00ae|[\u2000-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff]';
1102 : // the above sequence but with copyright, trade mark sign and option selection
1103 : static const _paddedUnicodeSequence =
1104 : r'(?:\u00a9|\u00ae|' + _unicodeSequences + r')[\ufe00-\ufe0f]?';
1105 : // should match a <img> tag with the matrix emote/emoticon attribute set
1106 : static const _matrixEmoticonSequence =
1107 : r'<img[^>]+data-mx-(?:emote|emoticon)(?==|>|\s)[^>]*>';
1108 :
1109 6 : static final RegExp _onlyEmojiRegex = RegExp(
1110 4 : r'^(' + _paddedUnicodeSequence + r'|\s)*$',
1111 : caseSensitive: false,
1112 : multiLine: false,
1113 : );
1114 6 : static final RegExp _onlyEmojiEmoteRegex = RegExp(
1115 8 : r'^(' + _paddedUnicodeSequence + r'|' + _matrixEmoticonSequence + r'|\s)*$',
1116 : caseSensitive: false,
1117 : multiLine: false,
1118 : );
1119 6 : static final RegExp _countEmojiRegex = RegExp(
1120 4 : r'(' + _paddedUnicodeSequence + r')',
1121 : caseSensitive: false,
1122 : multiLine: false,
1123 : );
1124 6 : static final RegExp _countEmojiEmoteRegex = RegExp(
1125 8 : r'(' + _paddedUnicodeSequence + r'|' + _matrixEmoticonSequence + r')',
1126 : caseSensitive: false,
1127 : multiLine: false,
1128 : );
1129 :
1130 : /// Returns if a given event only has emotes, emojis or whitespace as content.
1131 : /// If the body contains a reply then it is stripped.
1132 : /// This is useful to determine if stand-alone emotes should be displayed bigger.
1133 2 : bool get onlyEmotes {
1134 2 : if (isRichMessage) {
1135 : // calcUnlocalizedBody strips out the <img /> tags in favor of a :placeholder:
1136 4 : final formattedTextStripped = formattedText.replaceAll(
1137 2 : RegExp(
1138 : '<mx-reply>.*</mx-reply>',
1139 : caseSensitive: false,
1140 : multiLine: false,
1141 : dotAll: true,
1142 : ),
1143 : '',
1144 : );
1145 4 : return _onlyEmojiEmoteRegex.hasMatch(formattedTextStripped);
1146 : } else {
1147 6 : return _onlyEmojiRegex.hasMatch(plaintextBody);
1148 : }
1149 : }
1150 :
1151 : /// Gets the number of emotes in a given message. This is useful to determine
1152 : /// if the emotes should be displayed bigger.
1153 : /// If the body contains a reply then it is stripped.
1154 : /// WARNING: This does **not** test if there are only emotes. Use `event.onlyEmotes` for that!
1155 2 : int get numberEmotes {
1156 2 : if (isRichMessage) {
1157 : // calcUnlocalizedBody strips out the <img /> tags in favor of a :placeholder:
1158 4 : final formattedTextStripped = formattedText.replaceAll(
1159 2 : RegExp(
1160 : '<mx-reply>.*</mx-reply>',
1161 : caseSensitive: false,
1162 : multiLine: false,
1163 : dotAll: true,
1164 : ),
1165 : '',
1166 : );
1167 6 : return _countEmojiEmoteRegex.allMatches(formattedTextStripped).length;
1168 : } else {
1169 8 : return _countEmojiRegex.allMatches(plaintextBody).length;
1170 : }
1171 : }
1172 :
1173 : /// If this event is in Status SENDING and it aims to send a file, then this
1174 : /// shows the status of the file sending.
1175 0 : FileSendingStatus? get fileSendingStatus {
1176 0 : final status = unsigned?.tryGet<String>(fileSendingStatusKey);
1177 : if (status == null) return null;
1178 0 : return FileSendingStatus.values.singleWhereOrNull(
1179 0 : (fileSendingStatus) => fileSendingStatus.name == status,
1180 : );
1181 : }
1182 : }
1183 :
1184 : enum FileSendingStatus {
1185 : generatingThumbnail,
1186 : encrypting,
1187 : uploading,
1188 : }
|