LCOV - code coverage report
Current view: top level - lib/src - event.dart (source / functions) Hit Total Coverage
Test: merged.info Lines: 350 415 84.3 %
Date: 2024-09-30 15:57:20 Functions: 0 0 -

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

Generated by: LCOV version 1.14