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