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