741 lines
		
	
	
		
			27 KiB
		
	
	
	
		
			Dart
		
	
	
	
			
		
		
	
	
			741 lines
		
	
	
		
			27 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 'package:matrix_file_e2ee/matrix_file_e2ee.dart';
 | |
| 
 | |
| import '../famedlysdk.dart';
 | |
| import 'database/database.dart' show DbRoomState, DbEvent;
 | |
| import 'room.dart';
 | |
| import 'utils/matrix_localizations.dart';
 | |
| import 'utils/receipt.dart';
 | |
| import 'utils/run_in_background.dart';
 | |
| import 'utils/event_localizations.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 ?? '@unknown');
 | |
| 
 | |
|   @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.
 | |
|   /// -1=ERROR
 | |
|   ///  0=SENDING
 | |
|   ///  1=SENT
 | |
|   ///  2=TIMELINE
 | |
|   ///  3=ROOM_STATE
 | |
|   int status;
 | |
| 
 | |
|   static const int defaultStatus = 2;
 | |
|   static const Map<String, int> STATUS_TYPE = {
 | |
|     'ERROR': -1,
 | |
|     'SENDING': 0,
 | |
|     'SENT': 1,
 | |
|     'TIMELINE': 2,
 | |
|     'ROOM_STATE': 3,
 | |
|   };
 | |
| 
 | |
|   /// Optional. The event that redacted this event, if any. Otherwise null.
 | |
|   Event get redactedBecause =>
 | |
|       unsigned != null && unsigned['redacted_because'] is Map
 | |
|           ? Event.fromJson(unsigned['redacted_because'], room)
 | |
|           : null;
 | |
| 
 | |
|   bool get redacted => redactedBecause != null;
 | |
| 
 | |
|   User get stateKeyUser => room.getUserByMXIDSync(stateKey);
 | |
| 
 | |
|   double sortOrder;
 | |
| 
 | |
|   Event(
 | |
|       {this.status = defaultStatus,
 | |
|       Map<String, dynamic> content,
 | |
|       String type,
 | |
|       String eventId,
 | |
|       String roomId,
 | |
|       String senderId,
 | |
|       DateTime originServerTs,
 | |
|       Map<String, dynamic> unsigned,
 | |
|       Map<String, dynamic> prevContent,
 | |
|       String stateKey,
 | |
|       this.room,
 | |
|       this.sortOrder = 0.0}) {
 | |
|     this.content = content;
 | |
|     this.type = type;
 | |
|     this.eventId = eventId;
 | |
|     this.roomId = roomId ?? room?.id;
 | |
|     this.senderId = senderId;
 | |
|     this.unsigned = unsigned;
 | |
|     // synapse unfortunatley 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;
 | |
|     this.originServerTs = originServerTs;
 | |
|   }
 | |
| 
 | |
|   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, {
 | |
|     double sortOrder,
 | |
|     int status,
 | |
|   }) =>
 | |
|       Event(
 | |
|         status: status,
 | |
|         content: matrixEvent.content,
 | |
|         type: matrixEvent.type,
 | |
|         eventId: matrixEvent.eventId,
 | |
|         roomId: room.id,
 | |
|         senderId: matrixEvent.senderId,
 | |
|         originServerTs: matrixEvent.originServerTs,
 | |
|         unsigned: matrixEvent.unsigned,
 | |
|         prevContent: matrixEvent.prevContent,
 | |
|         stateKey: matrixEvent.stateKey,
 | |
|         room: room,
 | |
|         sortOrder: sortOrder,
 | |
|       );
 | |
| 
 | |
|   /// Get a State event from a table row or from the event stream.
 | |
|   factory Event.fromJson(Map<String, dynamic> jsonPayload, Room room,
 | |
|       [double sortOrder]) {
 | |
|     final content = Event.getMapFromPayload(jsonPayload['content']);
 | |
|     final unsigned = Event.getMapFromPayload(jsonPayload['unsigned']);
 | |
|     final prevContent = Event.getMapFromPayload(jsonPayload['prev_content']);
 | |
|     return Event(
 | |
|       status: jsonPayload['status'] ??
 | |
|           unsigned[MessageSendingStatusKey] ??
 | |
|           defaultStatus,
 | |
|       stateKey: jsonPayload['state_key'],
 | |
|       prevContent: prevContent,
 | |
|       content: content,
 | |
|       type: jsonPayload['type'],
 | |
|       eventId: jsonPayload['event_id'],
 | |
|       roomId: jsonPayload['room_id'],
 | |
|       senderId: jsonPayload['sender'],
 | |
|       originServerTs: jsonPayload.containsKey('origin_server_ts')
 | |
|           ? DateTime.fromMillisecondsSinceEpoch(jsonPayload['origin_server_ts'])
 | |
|           : DateTime.now(),
 | |
|       unsigned: unsigned,
 | |
|       room: room,
 | |
|       sortOrder: sortOrder ?? 0.0,
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   /// Get an event from either DbRoomState or DbEvent
 | |
|   factory Event.fromDb(dynamic dbEntry, Room room) {
 | |
|     if (!(dbEntry is DbRoomState || dbEntry is DbEvent)) {
 | |
|       throw ('Unknown db type');
 | |
|     }
 | |
|     final content = Event.getMapFromPayload(dbEntry.content);
 | |
|     final unsigned = Event.getMapFromPayload(dbEntry.unsigned);
 | |
|     final prevContent = Event.getMapFromPayload(dbEntry.prevContent);
 | |
|     return Event(
 | |
|       status: (dbEntry is DbEvent ? dbEntry.status : null) ?? defaultStatus,
 | |
|       stateKey: dbEntry.stateKey,
 | |
|       prevContent: prevContent,
 | |
|       content: content,
 | |
|       type: dbEntry.type,
 | |
|       eventId: dbEntry.eventId,
 | |
|       roomId: dbEntry.roomId,
 | |
|       senderId: dbEntry.sender,
 | |
|       originServerTs: dbEntry.originServerTs != null
 | |
|           ? DateTime.fromMillisecondsSinceEpoch(dbEntry.originServerTs)
 | |
|           : DateTime.now(),
 | |
|       unsigned: unsigned,
 | |
|       room: room,
 | |
|       sortOrder: dbEntry.sortOrder ?? 0.0,
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   @override
 | |
|   Map<String, dynamic> toJson() {
 | |
|     final data = <String, dynamic>{};
 | |
|     if (stateKey != null) data['state_key'] = stateKey;
 | |
|     if (prevContent != null && prevContent.isNotEmpty) {
 | |
|       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 != null && unsigned.isNotEmpty) {
 | |
|       data['unsigned'] = unsigned;
 | |
|     }
 | |
|     return data;
 | |
|   }
 | |
| 
 | |
|   User get asUser => User.fromState(
 | |
|       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;
 | |
|     var 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;
 | |
|     }
 | |
|     var toRemoveList = <String>[];
 | |
|     for (var entry in content.entries) {
 | |
|       if (!contentKeyWhiteList.contains(entry.key)) {
 | |
|         toRemoveList.add(entry.key);
 | |
|       }
 | |
|     }
 | |
|     toRemoveList.forEach((s) => content.remove(s));
 | |
|   }
 | |
| 
 | |
|   /// 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';
 | |
|   }
 | |
| 
 | |
|   /// Returns a list of [Receipt] instances for this event.
 | |
|   List<Receipt> get receipts {
 | |
|     if (!(room.roomAccountData.containsKey('m.receipt'))) return [];
 | |
|     var receiptsList = <Receipt>[];
 | |
|     for (var entry in room.roomAccountData['m.receipt'].content.entries) {
 | |
|       if (entry.value['event_id'] == eventId) {
 | |
|         receiptsList.add(Receipt(room.getUserByMXIDSync(entry.key),
 | |
|             DateTime.fromMillisecondsSinceEpoch(entry.value['ts'])));
 | |
|       }
 | |
|     }
 | |
|     return receiptsList;
 | |
|   }
 | |
| 
 | |
|   /// Removes this event if the status is < 1. This event will just be removed
 | |
|   /// from the database and the timelines. Returns false if not removed.
 | |
|   Future<bool> remove() async {
 | |
|     if (status < 1) {
 | |
|       await room.client.database?.removeEvent(room.client.id, eventId, room.id);
 | |
| 
 | |
|       room.client.onEvent.add(EventUpdate(
 | |
|           roomID: room.id,
 | |
|           type: EventUpdateType.timeline,
 | |
|           eventType: type,
 | |
|           content: {
 | |
|             'event_id': eventId,
 | |
|             'status': -2,
 | |
|             'content': {'body': 'Removed...'}
 | |
|           },
 | |
|           sortOrder: sortOrder));
 | |
|       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 != -1) 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. Returns [ErrorResponse] on error.
 | |
|   Future<dynamic> redact({String reason, String txid}) =>
 | |
|       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;
 | |
|     return 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 mimetipe 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 underyling mxc url of an attachment of a file event, or null if not present
 | |
|   String get attachmentMxcUrl =>
 | |
|       isAttachmentEncrypted ? content['file']['url'] : content['url'];
 | |
| 
 | |
|   /// Gets the underyling mxc url of a thumbnail of a file event, or null if not present
 | |
|   String get thumbnailMxcUrl => isThumbnailEncrypted
 | |
|       ? infoMap['thumbnail_file']['url']
 | |
|       : infoMap['thumbnail_url'];
 | |
| 
 | |
|   /// Gets the mxc url of an attachemnt/thumbnail of a file event, taking sizes into account, or null if not present
 | |
|   String 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
 | |
|   String 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 is String)) {
 | |
|       throw ("This event hasn't any attachment or thumbnail.");
 | |
|     }
 | |
|     getThumbnail = mxcUrl != attachmentMxcUrl;
 | |
|     // Is this file storeable?
 | |
|     final thisInfoMap = getThumbnail ? thumbnailInfoMap : infoMap;
 | |
|     var storeable = room.client.database != null &&
 | |
|         thisInfoMap['size'] is int &&
 | |
|         thisInfoMap['size'] <= room.client.database.maxFileSize;
 | |
| 
 | |
|     Uint8List uint8list;
 | |
|     if (storeable) {
 | |
|       uint8list = await room.client.database.getFile(mxcUrl);
 | |
|     }
 | |
|     return uint8list != null;
 | |
|   }
 | |
| 
 | |
|   /// Downloads (and decryptes 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(String) 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 mxcUrl = attachmentOrThumbnailMxcUrl(getThumbnail: getThumbnail);
 | |
|     if (!(mxcUrl is String)) {
 | |
|       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.');
 | |
|     }
 | |
|     final mxContent = Uri.parse(mxcUrl);
 | |
| 
 | |
|     Uint8List uint8list;
 | |
| 
 | |
|     // Is this file storeable?
 | |
|     final thisInfoMap = getThumbnail ? thumbnailInfoMap : infoMap;
 | |
|     var storeable = room.client.database != null &&
 | |
|         thisInfoMap['size'] is int &&
 | |
|         thisInfoMap['size'] <= room.client.database.maxFileSize;
 | |
| 
 | |
|     if (storeable) {
 | |
|       uint8list = await room.client.database.getFile(mxContent.toString());
 | |
|     }
 | |
| 
 | |
|     // Download the file
 | |
|     if (uint8list == null) {
 | |
|       downloadCallback ??= (String url) async {
 | |
|         return (await http.get(url)).bodyBytes;
 | |
|       };
 | |
|       uint8list =
 | |
|           await downloadCallback(mxContent.getDownloadLink(room.client));
 | |
|       storeable = storeable &&
 | |
|           uint8list.lengthInBytes < room.client.database.maxFileSize;
 | |
|       if (storeable) {
 | |
|         await room.client.database.storeFile(mxContent.toString(), 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();
 | |
|       encryptedFile.data = uint8list;
 | |
|       encryptedFile.iv = fileMap['iv'];
 | |
|       encryptedFile.k = fileMap['key']['k'];
 | |
|       encryptedFile.sha256 = fileMap['hashes']['sha256'];
 | |
|       uint8list = await runInBackground(decryptFile, encryptedFile);
 | |
|     }
 | |
|     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 '>'.
 | |
|   String getLocalizedBody(MatrixLocalizations i18n,
 | |
|       {bool withSenderNamePrefix = false, bool hideReply = false}) {
 | |
|     if (redacted) {
 | |
|       return i18n.removedBy(redactedBecause.sender.calcDisplayname());
 | |
|     }
 | |
|     final callback = EventLocalizations.localizationsMap[type];
 | |
|     var localizedBody = i18n.unknownEvent(type);
 | |
|     if (callback != null) {
 | |
|       localizedBody = callback(this, i18n);
 | |
|     }
 | |
| 
 | |
|     // Hide reply fallback
 | |
|     if (hideReply) {
 | |
|       localizedBody = localizedBody.replaceFirst(
 | |
|           RegExp(r'^>( \*)? <[^>]+>[^\n\r]+\r?\n(> [^\n]*\r?\n)*\r?\n'), '');
 | |
|     }
 | |
| 
 | |
|     // 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 != null && 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 {
 | |
|     if (content == null || !(content['m.relates_to'] is Map)) {
 | |
|       return null;
 | |
|     }
 | |
|     if (content['m.relates_to'].containsKey('event_id')) {
 | |
|       return content['m.relates_to']['event_id'];
 | |
|     }
 | |
|     if (content['m.relates_to']['m.in_reply_to'] is Map &&
 | |
|         content['m.relates_to']['m.in_reply_to'].containsKey('event_id')) {
 | |
|       return content['m.relates_to']['m.in_reply_to']['event_id'];
 | |
|     }
 | |
|     return null;
 | |
|   }
 | |
| 
 | |
|   /// Get wether 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.containsKey(eventId) &&
 | |
|       timeline.aggregatedEvents[eventId].containsKey(type);
 | |
| 
 | |
|   /// 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) =>
 | |
|       hasAggregatedEvents(timeline, 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.sortOrder - b.sortOrder > 0 ? 1 : -1);
 | |
|         var 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{2000}-\x{3300}]|\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|[\u2000-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])[\ufe00-\ufe0f]?|\s)*$',
 | |
|       caseSensitive: false,
 | |
|       multiLine: false);
 | |
|   static final RegExp _onlyEmojiEmoteRegex = RegExp(
 | |
|       r'^((?:\u00a9|\u00ae|[\u2000-\u3300]|\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|[\u2000-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])[\ufe00-\ufe0f]?)',
 | |
|       caseSensitive: false,
 | |
|       multiLine: false);
 | |
|   static final RegExp _countEmojiEmoteRegex = RegExp(
 | |
|       r'((?:\u00a9|\u00ae|[\u2000-\u3300]|\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.
 | |
|   /// This is useful to determine if stand-alone emotes should be displayed bigger.
 | |
|   bool get onlyEmotes => isRichMessage
 | |
|       ? _onlyEmojiEmoteRegex.hasMatch(formattedText)
 | |
|       : _onlyEmojiRegex.hasMatch(text);
 | |
| 
 | |
|   /// Gets the number of emotes in a given message. This is useful to determine if
 | |
|   /// emotes should be displayed bigger. WARNING: This does **not** test if there are
 | |
|   /// only emotes. Use `event.onlyEmotes` for that!
 | |
|   int get numberEmotes => isRichMessage
 | |
|       ? _countEmojiEmoteRegex.allMatches(formattedText).length
 | |
|       : _countEmojiRegex.allMatches(text).length;
 | |
| }
 |