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

Generated by: LCOV version 2.0-1