787 lines
		
	
	
		
			29 KiB
		
	
	
	
		
			Dart
		
	
	
	
			
		
		
	
	
			787 lines
		
	
	
		
			29 KiB
		
	
	
	
		
			Dart
		
	
	
	
| /*
 | |
|  *   Famedly Matrix SDK
 | |
|  *   Copyright (C) 2019, 2020, 2021 Famedly GmbH
 | |
|  *
 | |
|  *   This program is free software: you can redistribute it and/or modify
 | |
|  *   it under the terms of the GNU Affero General Public License as
 | |
|  *   published by the Free Software Foundation, either version 3 of the
 | |
|  *   License, or (at your option) any later version.
 | |
|  *
 | |
|  *   This program is distributed in the hope that it will be useful,
 | |
|  *   but WITHOUT ANY WARRANTY; without even the implied warranty of
 | |
|  *   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 | |
|  *   GNU Affero General Public License for more details.
 | |
|  *
 | |
|  *   You should have received a copy of the GNU Affero General Public License
 | |
|  *   along with this program.  If not, see <https://www.gnu.org/licenses/>.
 | |
|  */
 | |
| 
 | |
| import 'dart:convert';
 | |
| import 'dart:typed_data';
 | |
| 
 | |
| import 'package:http/http.dart' as http;
 | |
| 
 | |
| import '../matrix.dart';
 | |
| import 'utils/event_localizations.dart';
 | |
| import 'utils/html_to_text.dart';
 | |
| 
 | |
| abstract class RelationshipTypes {
 | |
|   static const String reply = 'm.in_reply_to';
 | |
|   static const String edit = 'm.replace';
 | |
|   static const String reaction = 'm.annotation';
 | |
| }
 | |
| 
 | |
| /// All data exchanged over Matrix is expressed as an "event". Typically each client action (e.g. sending a message) correlates with exactly one event.
 | |
| class Event extends MatrixEvent {
 | |
|   User get sender => room.getUserByMXIDSync(senderId);
 | |
| 
 | |
|   @Deprecated('Use [originServerTs] instead')
 | |
|   DateTime get time => originServerTs;
 | |
| 
 | |
|   @Deprecated('Use [type] instead')
 | |
|   String get typeKey => type;
 | |
| 
 | |
|   @Deprecated('Use [sender.calcDisplayname()] instead')
 | |
|   String? get senderName => sender.calcDisplayname();
 | |
| 
 | |
|   /// The room this event belongs to. May be null.
 | |
|   final Room room;
 | |
| 
 | |
|   /// The status of this event.
 | |
|   EventStatus status;
 | |
| 
 | |
|   static const EventStatus defaultStatus = EventStatus.synced;
 | |
| 
 | |
|   /// Optional. The event that redacted this event, if any. Otherwise null.
 | |
|   Event? get redactedBecause {
 | |
|     final redacted_because = unsigned?['redacted_because'];
 | |
|     final room = this.room;
 | |
|     return (redacted_because is Map<String, dynamic>)
 | |
|         ? Event.fromJson(redacted_because, room)
 | |
|         : null;
 | |
|   }
 | |
| 
 | |
|   bool get redacted => redactedBecause != null;
 | |
| 
 | |
|   User? get stateKeyUser => room.getUserByMXIDSync(stateKey!);
 | |
| 
 | |
|   Event({
 | |
|     this.status = defaultStatus,
 | |
|     required Map<String, dynamic> content,
 | |
|     required String type,
 | |
|     required String eventId,
 | |
|     required String senderId,
 | |
|     required DateTime originServerTs,
 | |
|     Map<String, dynamic>? unsigned,
 | |
|     Map<String, dynamic>? prevContent,
 | |
|     String? stateKey,
 | |
|     required this.room,
 | |
|   }) : super(
 | |
|           content: content,
 | |
|           type: type,
 | |
|           eventId: eventId,
 | |
|           senderId: senderId,
 | |
|           originServerTs: originServerTs,
 | |
|           roomId: room.id,
 | |
|         ) {
 | |
|     this.eventId = eventId;
 | |
|     this.unsigned = unsigned;
 | |
|     // synapse unfortunately isn't following the spec and tosses the prev_content
 | |
|     // into the unsigned block.
 | |
|     // Currently we are facing a very strange bug in web which is impossible to debug.
 | |
|     // It may be because of this line so we put this in try-catch until we can fix it.
 | |
|     try {
 | |
|       this.prevContent = (prevContent != null && prevContent.isNotEmpty)
 | |
|           ? prevContent
 | |
|           : (unsigned != null &&
 | |
|                   unsigned.containsKey('prev_content') &&
 | |
|                   unsigned['prev_content'] is Map)
 | |
|               ? unsigned['prev_content']
 | |
|               : null;
 | |
|     } catch (_) {
 | |
|       // A strange bug in dart web makes this crash
 | |
|     }
 | |
|     this.stateKey = stateKey;
 | |
| 
 | |
|     // Mark event as failed to send if status is `sending` and event is older
 | |
|     // than the timeout. This should not happen with the deprecated Moor
 | |
|     // database!
 | |
|     if (status.isSending && room.client.database != null) {
 | |
|       // Age of this event in milliseconds
 | |
|       final age = DateTime.now().millisecondsSinceEpoch -
 | |
|           originServerTs.millisecondsSinceEpoch;
 | |
| 
 | |
|       final room = this.room;
 | |
|       if (age > room.client.sendMessageTimeoutSeconds * 1000) {
 | |
|         // Update this event in database and open timelines
 | |
|         final json = toJson();
 | |
|         json['unsigned'] ??= <String, dynamic>{};
 | |
|         json['unsigned'][messageSendingStatusKey] = EventStatus.error.intValue;
 | |
|         room.client.handleSync(
 | |
|           SyncUpdate(
 | |
|             nextBatch: '',
 | |
|             rooms: RoomsUpdate(
 | |
|               join: {
 | |
|                 room.id: JoinedRoomUpdate(
 | |
|                   timeline: TimelineUpdate(
 | |
|                     events: [MatrixEvent.fromJson(json)],
 | |
|                   ),
 | |
|                 )
 | |
|               },
 | |
|             ),
 | |
|           ),
 | |
|         );
 | |
|       }
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   static Map<String, dynamic> getMapFromPayload(dynamic payload) {
 | |
|     if (payload is String) {
 | |
|       try {
 | |
|         return json.decode(payload);
 | |
|       } catch (e) {
 | |
|         return {};
 | |
|       }
 | |
|     }
 | |
|     if (payload is Map<String, dynamic>) return payload;
 | |
|     return {};
 | |
|   }
 | |
| 
 | |
|   factory Event.fromMatrixEvent(
 | |
|     MatrixEvent matrixEvent,
 | |
|     Room room, {
 | |
|     EventStatus status = defaultStatus,
 | |
|   }) =>
 | |
|       Event(
 | |
|         status: status,
 | |
|         content: matrixEvent.content,
 | |
|         type: matrixEvent.type,
 | |
|         eventId: matrixEvent.eventId,
 | |
|         senderId: matrixEvent.senderId,
 | |
|         originServerTs: matrixEvent.originServerTs,
 | |
|         unsigned: matrixEvent.unsigned,
 | |
|         prevContent: matrixEvent.prevContent,
 | |
|         stateKey: matrixEvent.stateKey,
 | |
|         room: room,
 | |
|       );
 | |
| 
 | |
|   /// Get a State event from a table row or from the event stream.
 | |
|   factory Event.fromJson(
 | |
|     Map<String, dynamic> jsonPayload,
 | |
|     Room room,
 | |
|   ) {
 | |
|     final content = Event.getMapFromPayload(jsonPayload['content']);
 | |
|     final unsigned = Event.getMapFromPayload(jsonPayload['unsigned']);
 | |
|     final prevContent = Event.getMapFromPayload(jsonPayload['prev_content']);
 | |
|     return Event(
 | |
|       status: eventStatusFromInt(jsonPayload['status'] ??
 | |
|           unsigned[messageSendingStatusKey] ??
 | |
|           defaultStatus.intValue),
 | |
|       stateKey: jsonPayload['state_key'],
 | |
|       prevContent: prevContent,
 | |
|       content: content,
 | |
|       type: jsonPayload['type'],
 | |
|       eventId: jsonPayload['event_id'] ?? '',
 | |
|       senderId: jsonPayload['sender'],
 | |
|       originServerTs: jsonPayload.containsKey('origin_server_ts')
 | |
|           ? DateTime.fromMillisecondsSinceEpoch(jsonPayload['origin_server_ts'])
 | |
|           : DateTime.now(),
 | |
|       unsigned: unsigned,
 | |
|       room: room,
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   @override
 | |
|   Map<String, dynamic> toJson() {
 | |
|     final data = <String, dynamic>{};
 | |
|     if (stateKey != null) data['state_key'] = stateKey;
 | |
|     if (prevContent?.isNotEmpty == true) {
 | |
|       data['prev_content'] = prevContent;
 | |
|     }
 | |
|     data['content'] = content;
 | |
|     data['type'] = type;
 | |
|     data['event_id'] = eventId;
 | |
|     data['room_id'] = roomId;
 | |
|     data['sender'] = senderId;
 | |
|     data['origin_server_ts'] = originServerTs.millisecondsSinceEpoch;
 | |
|     if (unsigned?.isNotEmpty == true) {
 | |
|       data['unsigned'] = unsigned;
 | |
|     }
 | |
|     return data;
 | |
|   }
 | |
| 
 | |
|   User get asUser => User.fromState(
 | |
|       // state key should always be set for member events
 | |
|       stateKey: stateKey!,
 | |
|       prevContent: prevContent,
 | |
|       content: content,
 | |
|       typeKey: type,
 | |
|       eventId: eventId,
 | |
|       roomId: roomId,
 | |
|       senderId: senderId,
 | |
|       originServerTs: originServerTs,
 | |
|       unsigned: unsigned,
 | |
|       room: room);
 | |
| 
 | |
|   String get messageType => type == EventTypes.Sticker
 | |
|       ? MessageTypes.Sticker
 | |
|       : (content['msgtype'] is String ? content['msgtype'] : MessageTypes.Text);
 | |
| 
 | |
|   void setRedactionEvent(Event redactedBecause) {
 | |
|     unsigned = {
 | |
|       'redacted_because': redactedBecause.toJson(),
 | |
|     };
 | |
|     prevContent = null;
 | |
|     final contentKeyWhiteList = <String>[];
 | |
|     switch (type) {
 | |
|       case EventTypes.RoomMember:
 | |
|         contentKeyWhiteList.add('membership');
 | |
|         break;
 | |
|       case EventTypes.RoomCreate:
 | |
|         contentKeyWhiteList.add('creator');
 | |
|         break;
 | |
|       case EventTypes.RoomJoinRules:
 | |
|         contentKeyWhiteList.add('join_rule');
 | |
|         break;
 | |
|       case EventTypes.RoomPowerLevels:
 | |
|         contentKeyWhiteList.add('ban');
 | |
|         contentKeyWhiteList.add('events');
 | |
|         contentKeyWhiteList.add('events_default');
 | |
|         contentKeyWhiteList.add('kick');
 | |
|         contentKeyWhiteList.add('redact');
 | |
|         contentKeyWhiteList.add('state_default');
 | |
|         contentKeyWhiteList.add('users');
 | |
|         contentKeyWhiteList.add('users_default');
 | |
|         break;
 | |
|       case EventTypes.RoomAliases:
 | |
|         contentKeyWhiteList.add('aliases');
 | |
|         break;
 | |
|       case EventTypes.HistoryVisibility:
 | |
|         contentKeyWhiteList.add('history_visibility');
 | |
|         break;
 | |
|       default:
 | |
|         break;
 | |
|     }
 | |
|     content.removeWhere((k, v) => !contentKeyWhiteList.contains(k));
 | |
|   }
 | |
| 
 | |
|   /// Returns the body of this event if it has a body.
 | |
|   String get text => content['body'] is String ? content['body'] : '';
 | |
| 
 | |
|   /// Returns the formatted boy of this event if it has a formatted body.
 | |
|   String get formattedText =>
 | |
|       content['formatted_body'] is String ? content['formatted_body'] : '';
 | |
| 
 | |
|   /// Use this to get the body.
 | |
|   String get body {
 | |
|     if (redacted) return 'Redacted';
 | |
|     if (text != '') return text;
 | |
|     if (formattedText != '') return formattedText;
 | |
|     return '$type';
 | |
|   }
 | |
| 
 | |
|   /// Use this to get a plain-text representation of the event, stripping things
 | |
|   /// like spoilers and thelike. Useful for plain text notifications.
 | |
|   String get plaintextBody => content['format'] == 'org.matrix.custom.html'
 | |
|       ? HtmlToText.convert(formattedText)
 | |
|       : body;
 | |
| 
 | |
|   /// Returns a list of [Receipt] instances for this event.
 | |
|   List<Receipt> get receipts {
 | |
|     final room = this.room;
 | |
|     final receipt = room.roomAccountData['m.receipt'];
 | |
|     if (receipt == null) return [];
 | |
|     return receipt.content.entries
 | |
|         .where((entry) => entry.value['event_id'] == eventId)
 | |
|         .map((entry) => Receipt(room.getUserByMXIDSync(entry.key),
 | |
|             DateTime.fromMillisecondsSinceEpoch(entry.value['ts'])))
 | |
|         .toList();
 | |
|   }
 | |
| 
 | |
|   /// Removes this event if the status is [sending], [error] or [removed].
 | |
|   /// This event will just be removed from the database and the timelines.
 | |
|   /// Returns [false] if not removed.
 | |
|   Future<bool> remove() async {
 | |
|     final room = this.room;
 | |
| 
 | |
|     if (!status.isSent) {
 | |
|       await room.client.database?.removeEvent(eventId, room.id);
 | |
| 
 | |
|       room.client.onEvent.add(EventUpdate(
 | |
|         roomID: room.id,
 | |
|         type: EventUpdateType.timeline,
 | |
|         content: {
 | |
|           'event_id': eventId,
 | |
|           'status': EventStatus.removed.intValue,
 | |
|           'content': {'body': 'Removed...'}
 | |
|         },
 | |
|       ));
 | |
|       return true;
 | |
|     }
 | |
|     return false;
 | |
|   }
 | |
| 
 | |
|   /// Try to send this event again. Only works with events of status -1.
 | |
|   Future<String?> sendAgain({String? txid}) async {
 | |
|     if (!status.isError) return null;
 | |
|     // we do not remove the event here. It will automatically be updated
 | |
|     // in the `sendEvent` method to transition -1 -> 0 -> 1 -> 2
 | |
|     final newEventId = await room.sendEvent(
 | |
|       content,
 | |
|       txid: txid ?? unsigned?['transaction_id'] ?? eventId,
 | |
|     );
 | |
|     return newEventId;
 | |
|   }
 | |
| 
 | |
|   /// Whether the client is allowed to redact this event.
 | |
|   bool get canRedact => senderId == room.client.userID || room.canRedact;
 | |
| 
 | |
|   /// Redacts this event. Throws `ErrorResponse` on error.
 | |
|   Future<String?> redactEvent({String? reason, String? txid}) async =>
 | |
|       await room.redactEvent(eventId, reason: reason, txid: txid);
 | |
| 
 | |
|   /// Searches for the reply event in the given timeline.
 | |
|   Future<Event?> getReplyEvent(Timeline timeline) async {
 | |
|     if (relationshipType != RelationshipTypes.reply) return null;
 | |
|     final relationshipEventId = this.relationshipEventId;
 | |
|     return relationshipEventId == null
 | |
|         ? null
 | |
|         : await timeline.getEventById(relationshipEventId);
 | |
|   }
 | |
| 
 | |
|   /// If this event is encrypted and the decryption was not successful because
 | |
|   /// the session is unknown, this requests the session key from other devices
 | |
|   /// in the room. If the event is not encrypted or the decryption failed because
 | |
|   /// of a different error, this throws an exception.
 | |
|   Future<void> requestKey() async {
 | |
|     if (type != EventTypes.Encrypted ||
 | |
|         messageType != MessageTypes.BadEncrypted ||
 | |
|         content['can_request_session'] != true) {
 | |
|       throw ('Session key not requestable');
 | |
|     }
 | |
|     await room.requestSessionKey(content['session_id'], content['sender_key']);
 | |
|     return;
 | |
|   }
 | |
| 
 | |
|   /// Gets the info map of file events, or a blank map if none present
 | |
|   Map get infoMap =>
 | |
|       content['info'] is Map ? content['info'] : <String, dynamic>{};
 | |
| 
 | |
|   /// Gets the thumbnail info map of file events, or a blank map if nonepresent
 | |
|   Map get thumbnailInfoMap => infoMap['thumbnail_info'] is Map
 | |
|       ? infoMap['thumbnail_info']
 | |
|       : <String, dynamic>{};
 | |
| 
 | |
|   /// Returns if a file event has an attachment
 | |
|   bool get hasAttachment => content['url'] is String || content['file'] is Map;
 | |
| 
 | |
|   /// Returns if a file event has a thumbnail
 | |
|   bool get hasThumbnail =>
 | |
|       infoMap['thumbnail_url'] is String || infoMap['thumbnail_file'] is Map;
 | |
| 
 | |
|   /// Returns if a file events attachment is encrypted
 | |
|   bool get isAttachmentEncrypted => content['file'] is Map;
 | |
| 
 | |
|   /// Returns if a file events thumbnail is encrypted
 | |
|   bool get isThumbnailEncrypted => infoMap['thumbnail_file'] is Map;
 | |
| 
 | |
|   /// Gets the mimetype of the attachment of a file event, or a blank string if not present
 | |
|   String get attachmentMimetype => infoMap['mimetype'] is String
 | |
|       ? infoMap['mimetype'].toLowerCase()
 | |
|       : (content['file'] is Map && content['file']['mimetype'] is String
 | |
|           ? content['file']['mimetype']
 | |
|           : '');
 | |
| 
 | |
|   /// Gets the mimetype of the thumbnail of a file event, or a blank string if not present
 | |
|   String get thumbnailMimetype => thumbnailInfoMap['mimetype'] is String
 | |
|       ? thumbnailInfoMap['mimetype'].toLowerCase()
 | |
|       : (infoMap['thumbnail_file'] is Map &&
 | |
|               infoMap['thumbnail_file']['mimetype'] is String
 | |
|           ? infoMap['thumbnail_file']['mimetype']
 | |
|           : '');
 | |
| 
 | |
|   /// Gets the underlying mxc url of an attachment of a file event, or null if not present
 | |
|   Uri? get attachmentMxcUrl {
 | |
|     final url = isAttachmentEncrypted ? content['file']['url'] : content['url'];
 | |
|     return url is String ? Uri.tryParse(url) : null;
 | |
|   }
 | |
| 
 | |
|   /// Gets the underlying mxc url of a thumbnail of a file event, or null if not present
 | |
|   Uri? get thumbnailMxcUrl {
 | |
|     final url = isThumbnailEncrypted
 | |
|         ? infoMap['thumbnail_file']['url']
 | |
|         : infoMap['thumbnail_url'];
 | |
|     return url is String ? Uri.tryParse(url) : null;
 | |
|   }
 | |
| 
 | |
|   /// Gets the mxc url of an attachment/thumbnail of a file event, taking sizes into account, or null if not present
 | |
|   Uri? attachmentOrThumbnailMxcUrl({bool getThumbnail = false}) {
 | |
|     if (getThumbnail &&
 | |
|         infoMap['size'] is int &&
 | |
|         thumbnailInfoMap['size'] is int &&
 | |
|         infoMap['size'] <= thumbnailInfoMap['size']) {
 | |
|       getThumbnail = false;
 | |
|     }
 | |
|     if (getThumbnail && !hasThumbnail) {
 | |
|       getThumbnail = false;
 | |
|     }
 | |
|     return getThumbnail ? thumbnailMxcUrl : attachmentMxcUrl;
 | |
|   }
 | |
| 
 | |
|   // size determined from an approximate 800x800 jpeg thumbnail with method=scale
 | |
|   static const _minNoThumbSize = 80 * 1024;
 | |
| 
 | |
|   /// Gets the attachment https URL to display in the timeline, taking into account if the original image is tiny.
 | |
|   /// Returns null for encrypted rooms, if the image can't be fetched via http url or if the event does not contain an attachment.
 | |
|   /// Set [getThumbnail] to true to fetch the thumbnail, set [width], [height] and [method]
 | |
|   /// for the respective thumbnailing properties.
 | |
|   /// [minNoThumbSize] is the minimum size that an original image may be to not fetch its thumbnail, defaults to 80k
 | |
|   /// [useThumbnailMxcUrl] says weather to use the mxc url of the thumbnail, rather than the original attachment.
 | |
|   ///  [animated] says weather the thumbnail is animated
 | |
|   Uri? getAttachmentUrl(
 | |
|       {bool getThumbnail = false,
 | |
|       bool useThumbnailMxcUrl = false,
 | |
|       double width = 800.0,
 | |
|       double height = 800.0,
 | |
|       ThumbnailMethod method = ThumbnailMethod.scale,
 | |
|       int minNoThumbSize = _minNoThumbSize,
 | |
|       bool animated = false}) {
 | |
|     if (![EventTypes.Message, EventTypes.Sticker].contains(type) ||
 | |
|         !hasAttachment ||
 | |
|         isAttachmentEncrypted) {
 | |
|       return null; // can't url-thumbnail in encrypted rooms
 | |
|     }
 | |
|     if (useThumbnailMxcUrl && !hasThumbnail) {
 | |
|       return null; // can't fetch from thumbnail
 | |
|     }
 | |
|     final thisInfoMap = useThumbnailMxcUrl ? thumbnailInfoMap : infoMap;
 | |
|     final thisMxcUrl =
 | |
|         useThumbnailMxcUrl ? infoMap['thumbnail_url'] : content['url'];
 | |
|     // if we have as method scale, we can return safely the original image, should it be small enough
 | |
|     if (getThumbnail &&
 | |
|         method == ThumbnailMethod.scale &&
 | |
|         thisInfoMap['size'] is int &&
 | |
|         thisInfoMap['size'] < minNoThumbSize) {
 | |
|       getThumbnail = false;
 | |
|     }
 | |
|     // now generate the actual URLs
 | |
|     if (getThumbnail) {
 | |
|       return Uri.parse(thisMxcUrl).getThumbnail(
 | |
|         room.client,
 | |
|         width: width,
 | |
|         height: height,
 | |
|         method: method,
 | |
|         animated: animated,
 | |
|       );
 | |
|     } else {
 | |
|       return Uri.parse(thisMxcUrl).getDownloadLink(room.client);
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   /// Returns if an attachment is in the local store
 | |
|   Future<bool> isAttachmentInLocalStore({bool getThumbnail = false}) async {
 | |
|     if (![EventTypes.Message, EventTypes.Sticker].contains(type)) {
 | |
|       throw ("This event has the type '$type' and so it can't contain an attachment.");
 | |
|     }
 | |
|     final mxcUrl = attachmentOrThumbnailMxcUrl(getThumbnail: getThumbnail);
 | |
|     if (mxcUrl == null) {
 | |
|       throw "This event hasn't any attachment or thumbnail.";
 | |
|     }
 | |
|     getThumbnail = mxcUrl != attachmentMxcUrl;
 | |
|     // Is this file storeable?
 | |
|     final thisInfoMap = getThumbnail ? thumbnailInfoMap : infoMap;
 | |
|     final database = room.client.database;
 | |
|     if (database == null) {
 | |
|       return false;
 | |
|     }
 | |
| 
 | |
|     final storeable = thisInfoMap['size'] is int &&
 | |
|         thisInfoMap['size'] <= database.maxFileSize;
 | |
| 
 | |
|     Uint8List? uint8list;
 | |
|     if (storeable) {
 | |
|       uint8list = await database.getFile(mxcUrl);
 | |
|     }
 | |
|     return uint8list != null;
 | |
|   }
 | |
| 
 | |
|   /// Downloads (and decrypts if necessary) the attachment of this
 | |
|   /// event and returns it as a [MatrixFile]. If this event doesn't
 | |
|   /// contain an attachment, this throws an error. Set [getThumbnail] to
 | |
|   /// true to download the thumbnail instead.
 | |
|   Future<MatrixFile> downloadAndDecryptAttachment(
 | |
|       {bool getThumbnail = false,
 | |
|       Future<Uint8List> Function(Uri)? downloadCallback}) async {
 | |
|     if (![EventTypes.Message, EventTypes.Sticker].contains(type)) {
 | |
|       throw ("This event has the type '$type' and so it can't contain an attachment.");
 | |
|     }
 | |
|     final database = room.client.database;
 | |
|     final mxcUrl = attachmentOrThumbnailMxcUrl(getThumbnail: getThumbnail);
 | |
|     if (mxcUrl == null) {
 | |
|       throw "This event hasn't any attachment or thumbnail.";
 | |
|     }
 | |
|     getThumbnail = mxcUrl != attachmentMxcUrl;
 | |
|     final isEncrypted =
 | |
|         getThumbnail ? isThumbnailEncrypted : isAttachmentEncrypted;
 | |
|     if (isEncrypted && !room.client.encryptionEnabled) {
 | |
|       throw ('Encryption is not enabled in your Client.');
 | |
|     }
 | |
| 
 | |
|     // Is this file storeable?
 | |
|     final thisInfoMap = getThumbnail ? thumbnailInfoMap : infoMap;
 | |
|     var storeable = database != null &&
 | |
|         thisInfoMap['size'] is int &&
 | |
|         thisInfoMap['size'] <= database.maxFileSize;
 | |
| 
 | |
|     Uint8List? uint8list;
 | |
|     if (storeable) {
 | |
|       uint8list = await room.client.database?.getFile(mxcUrl);
 | |
|     }
 | |
| 
 | |
|     // Download the file
 | |
|     if (uint8list == null) {
 | |
|       downloadCallback ??= (Uri url) async => (await http.get(url)).bodyBytes;
 | |
|       uint8list = await downloadCallback(mxcUrl.getDownloadLink(room.client));
 | |
|       storeable = database != null &&
 | |
|           storeable &&
 | |
|           uint8list.lengthInBytes < database.maxFileSize;
 | |
|       if (storeable) {
 | |
|         await database.storeFile(
 | |
|             mxcUrl, uint8list, DateTime.now().millisecondsSinceEpoch);
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     // Decrypt the file
 | |
|     if (isEncrypted) {
 | |
|       final fileMap =
 | |
|           getThumbnail ? infoMap['thumbnail_file'] : content['file'];
 | |
|       if (!fileMap['key']['key_ops'].contains('decrypt')) {
 | |
|         throw ("Missing 'decrypt' in 'key_ops'.");
 | |
|       }
 | |
|       final encryptedFile = EncryptedFile(
 | |
|         data: uint8list,
 | |
|         iv: fileMap['iv'],
 | |
|         k: fileMap['key']['k'],
 | |
|         sha256: fileMap['hashes']['sha256'],
 | |
|       );
 | |
|       uint8list = await room.client.runInBackground<Uint8List?, EncryptedFile>(
 | |
|           decryptFile, encryptedFile);
 | |
|       if (uint8list == null) {
 | |
|         throw ('Unable to decrypt file');
 | |
|       }
 | |
|     }
 | |
|     return MatrixFile(bytes: uint8list, name: body);
 | |
|   }
 | |
| 
 | |
|   /// Returns if this is a known event type.
 | |
|   bool get isEventTypeKnown =>
 | |
|       EventLocalizations.localizationsMap.containsKey(type);
 | |
| 
 | |
|   /// Returns a localized String representation of this event. For a
 | |
|   /// room list you may find [withSenderNamePrefix] useful. Set [hideReply] to
 | |
|   /// crop all lines starting with '>'. With [plaintextBody] it'll use the
 | |
|   /// plaintextBody instead of the normal body.
 | |
|   String getLocalizedBody(
 | |
|     MatrixLocalizations i18n, {
 | |
|     bool withSenderNamePrefix = false,
 | |
|     bool hideReply = false,
 | |
|     bool hideEdit = false,
 | |
|     bool plaintextBody = false,
 | |
|   }) {
 | |
|     if (redacted) {
 | |
|       return i18n.removedBy(redactedBecause?.sender.calcDisplayname() ?? '');
 | |
|     }
 | |
|     var body = plaintextBody ? this.plaintextBody : this.body;
 | |
| 
 | |
|     // we need to know if the message is an html message to be able to determine
 | |
|     // if we need to strip the reply fallback.
 | |
|     var htmlMessage = content['format'] != 'org.matrix.custom.html';
 | |
|     // If we have an edit, we want to operate on the new content
 | |
|     if (hideEdit &&
 | |
|         relationshipType == RelationshipTypes.edit &&
 | |
|         content.tryGet<Map<String, dynamic>>('m.new_content') != null) {
 | |
|       if (plaintextBody &&
 | |
|           content['m.new_content']['format'] == 'org.matrix.custom.html') {
 | |
|         htmlMessage = true;
 | |
|         body = HtmlToText.convert(
 | |
|             (content['m.new_content'] as Map<String, dynamic>)
 | |
|                     .tryGet<String>('formatted_body') ??
 | |
|                 formattedText);
 | |
|       } else {
 | |
|         htmlMessage = false;
 | |
|         body = (content['m.new_content'] as Map<String, dynamic>)
 | |
|                 .tryGet<String>('body') ??
 | |
|             body;
 | |
|       }
 | |
|     }
 | |
|     // Hide reply fallback
 | |
|     // Be sure that the plaintextBody already stripped teh reply fallback,
 | |
|     // if the message is formatted
 | |
|     if (hideReply && (!plaintextBody || htmlMessage)) {
 | |
|       body = body.replaceFirst(
 | |
|           RegExp(r'^>( \*)? <[^>]+>[^\n\r]+\r?\n(> [^\n]*\r?\n)*\r?\n'), '');
 | |
|     }
 | |
|     final callback = EventLocalizations.localizationsMap[type];
 | |
|     var localizedBody = i18n.unknownEvent(type);
 | |
|     if (callback != null) {
 | |
|       localizedBody = callback(this, i18n, body);
 | |
|     }
 | |
| 
 | |
|     // Add the sender name prefix
 | |
|     if (withSenderNamePrefix &&
 | |
|         type == EventTypes.Message &&
 | |
|         textOnlyMessageTypes.contains(messageType)) {
 | |
|       final senderNameOrYou = senderId == room.client.userID
 | |
|           ? i18n.you
 | |
|           : (sender.calcDisplayname());
 | |
|       localizedBody = '$senderNameOrYou: $localizedBody';
 | |
|     }
 | |
| 
 | |
|     return localizedBody;
 | |
|   }
 | |
| 
 | |
|   static const Set<String> textOnlyMessageTypes = {
 | |
|     MessageTypes.Text,
 | |
|     MessageTypes.Notice,
 | |
|     MessageTypes.Emote,
 | |
|     MessageTypes.None,
 | |
|   };
 | |
| 
 | |
|   /// returns if this event matches the passed event or transaction id
 | |
|   bool matchesEventOrTransactionId(String? search) {
 | |
|     if (search == null) {
 | |
|       return false;
 | |
|     }
 | |
|     if (eventId == search) {
 | |
|       return true;
 | |
|     }
 | |
|     return unsigned?['transaction_id'] == search;
 | |
|   }
 | |
| 
 | |
|   /// Get the relationship type of an event. `null` if there is none
 | |
|   String? get relationshipType {
 | |
|     if (content.tryGet<Map<String, dynamic>>('m.relates_to') == null) {
 | |
|       return null;
 | |
|     }
 | |
|     if (content['m.relates_to'].containsKey('m.in_reply_to')) {
 | |
|       return RelationshipTypes.reply;
 | |
|     }
 | |
|     return content
 | |
|         .tryGet<Map<String, dynamic>>('m.relates_to')
 | |
|         ?.tryGet<String>('rel_type');
 | |
|   }
 | |
| 
 | |
|   /// Get the event ID that this relationship will reference. `null` if there is none
 | |
|   String? get relationshipEventId {
 | |
|     final relatesToMap = content.tryGetMap<String, dynamic>('m.relates_to');
 | |
|     return relatesToMap?.tryGet<String>('event_id') ??
 | |
|         relatesToMap
 | |
|             ?.tryGetMap<String, dynamic>('m.in_reply_to')
 | |
|             ?.tryGet<String>('event_id');
 | |
|   }
 | |
| 
 | |
|   /// Get whether this event has aggregated events from a certain [type]
 | |
|   /// To be able to do that you need to pass a [timeline]
 | |
|   bool hasAggregatedEvents(Timeline timeline, String type) =>
 | |
|       timeline.aggregatedEvents[eventId]?.containsKey(type) == true;
 | |
| 
 | |
|   /// Get all the aggregated event objects for a given [type]. To be able to do this
 | |
|   /// you have to pass a [timeline]
 | |
|   Set<Event> aggregatedEvents(Timeline timeline, String type) =>
 | |
|       timeline.aggregatedEvents[eventId]?[type] ?? <Event>{};
 | |
| 
 | |
|   /// Fetches the event to be rendered, taking into account all the edits and the like.
 | |
|   /// It needs a [timeline] for that.
 | |
|   Event getDisplayEvent(Timeline timeline) {
 | |
|     if (redacted) {
 | |
|       return this;
 | |
|     }
 | |
|     if (hasAggregatedEvents(timeline, RelationshipTypes.edit)) {
 | |
|       // alright, we have an edit
 | |
|       final allEditEvents = aggregatedEvents(timeline, RelationshipTypes.edit)
 | |
|           // we only allow edits made by the original author themself
 | |
|           .where((e) => e.senderId == senderId && e.type == EventTypes.Message)
 | |
|           .toList();
 | |
|       // we need to check again if it isn't empty, as we potentially removed all
 | |
|       // aggregated edits
 | |
|       if (allEditEvents.isNotEmpty) {
 | |
|         allEditEvents.sort((a, b) => a.originServerTs.millisecondsSinceEpoch -
 | |
|                     b.originServerTs.millisecondsSinceEpoch >
 | |
|                 0
 | |
|             ? 1
 | |
|             : -1);
 | |
|         final rawEvent = allEditEvents.last.toJson();
 | |
|         // update the content of the new event to render
 | |
|         if (rawEvent['content']['m.new_content'] is Map) {
 | |
|           rawEvent['content'] = rawEvent['content']['m.new_content'];
 | |
|         }
 | |
|         return Event.fromJson(rawEvent, room);
 | |
|       }
 | |
|     }
 | |
|     return this;
 | |
|   }
 | |
| 
 | |
|   /// returns if a message is a rich message
 | |
|   bool get isRichMessage =>
 | |
|       content['format'] == 'org.matrix.custom.html' &&
 | |
|       content['formatted_body'] is String;
 | |
| 
 | |
|   // regexes to fetch the number of emotes, including emoji, and if the message consists of only those
 | |
|   // to match an emoji we can use the following regex:
 | |
|   // (?:\x{00a9}|\x{00ae}|[\x{2600}-\x{27bf}]|[\x{2b00}-\x{2bff}]|\x{d83c}[\x{d000}-\x{dfff}]|\x{d83d}[\x{d000}-\x{dfff}]|\x{d83e}[\x{d000}-\x{dfff}])[\x{fe00}-\x{fe0f}]?
 | |
|   // we need to replace \x{0000} with \u0000, the comment is left in the other format to be able to paste into regex101.com
 | |
|   // to see if there is a custom emote, we use the following regex: <img[^>]+data-mx-(?:emote|emoticon)(?==|>|\s)[^>]*>
 | |
|   // now we combind the two to have four regexes:
 | |
|   // 1. are there only emoji, or whitespace
 | |
|   // 2. are there only emoji, emotes, or whitespace
 | |
|   // 3. count number of emoji
 | |
|   // 4- count number of emoji or emotes
 | |
|   static final RegExp _onlyEmojiRegex = RegExp(
 | |
|       r'^((?:\u00a9|\u00ae|[\u2600-\u27bf]|[\u2b00-\u2bff]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])[\ufe00-\ufe0f]?|\s)*$',
 | |
|       caseSensitive: false,
 | |
|       multiLine: false);
 | |
|   static final RegExp _onlyEmojiEmoteRegex = RegExp(
 | |
|       r'^((?:\u00a9|\u00ae|[\u2600-\u27bf]|[\u2b00-\u2bff]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])[\ufe00-\ufe0f]?|<img[^>]+data-mx-(?:emote|emoticon)(?==|>|\s)[^>]*>|\s)*$',
 | |
|       caseSensitive: false,
 | |
|       multiLine: false);
 | |
|   static final RegExp _countEmojiRegex = RegExp(
 | |
|       r'((?:\u00a9|\u00ae|[\u2600-\u27bf]|[\u2b00-\u2bff]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])[\ufe00-\ufe0f]?)',
 | |
|       caseSensitive: false,
 | |
|       multiLine: false);
 | |
|   static final RegExp _countEmojiEmoteRegex = RegExp(
 | |
|       r'((?:\u00a9|\u00ae|[\u2600-\u27bf]|[\u2b00-\u2bff]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])[\ufe00-\ufe0f]?|<img[^>]+data-mx-(?:emote|emoticon)(?==|>|\s)[^>]*>)',
 | |
|       caseSensitive: false,
 | |
|       multiLine: false);
 | |
| 
 | |
|   /// Returns if a given event only has emotes, emojis or whitespace as content.
 | |
|   /// If the body contains a reply then it is stripped.
 | |
|   /// This is useful to determine if stand-alone emotes should be displayed bigger.
 | |
|   bool get onlyEmotes {
 | |
|     if (isRichMessage) {
 | |
|       final formattedTextStripped = formattedText.replaceAll(
 | |
|           RegExp('<mx-reply>.*<\/mx-reply>',
 | |
|               caseSensitive: false, multiLine: false, dotAll: true),
 | |
|           '');
 | |
|       return _onlyEmojiEmoteRegex.hasMatch(formattedTextStripped);
 | |
|     } else {
 | |
|       return _onlyEmojiRegex.hasMatch(plaintextBody);
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   /// Gets the number of emotes in a given message. This is useful to determine
 | |
|   /// if the emotes should be displayed bigger.
 | |
|   /// If the body contains a reply then it is stripped.
 | |
|   /// WARNING: This does **not** test if there are only emotes. Use `event.onlyEmotes` for that!
 | |
|   int get numberEmotes {
 | |
|     if (isRichMessage) {
 | |
|       final formattedTextStripped = formattedText.replaceAll(
 | |
|           RegExp('<mx-reply>.*<\/mx-reply>',
 | |
|               caseSensitive: false, multiLine: false, dotAll: true),
 | |
|           '');
 | |
|       return _countEmojiEmoteRegex.allMatches(formattedTextStripped).length;
 | |
|     } else {
 | |
|       return _countEmojiRegex.allMatches(plaintextBody).length;
 | |
|     }
 | |
|   }
 | |
| }
 |