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

Generated by: LCOV version 1.14