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

Generated by: LCOV version 2.0-1