/* * 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 . */ 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 statusType = { 'error': -1, 'sending': 0, 'sent': 1, 'timeline': 2, 'roomState': 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 content, String type, String eventId, String roomId, String senderId, DateTime originServerTs, Map unsigned, Map 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 getMapFromPayload(dynamic payload) { if (payload is String) { try { return json.decode(payload); } catch (e) { return {}; } } if (payload is Map) 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 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 toJson() { final data = {}; 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 = []; 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'; } /// Returns a list of [Receipt] instances for this event. List get receipts { if (!(room.roomAccountData.containsKey('m.receipt'))) return []; var receiptsList = []; 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 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, 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 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. Throws `ErrorResponse` on error. Future redact({String reason, String txid}) => room.redactEvent(eventId, reason: reason, txid: txid); /// Searches for the reply event in the given timeline. Future 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 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'] : {}; /// 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'] : {}; /// 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 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 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 downloadAndDecryptAttachment( {bool getThumbnail = false, Future 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 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 ??= (Uri 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( data: uint8list, iv: fileMap['iv'], k: fileMap['key']['k'], 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 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>('m.relates_to') == null) { return null; } if (content['m.relates_to'].containsKey('m.in_reply_to')) { return RelationshipTypes.reply; } return content .tryGet>('m.relates_to') .tryGet('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 aggregatedEvents(Timeline timeline, String type) => hasAggregatedEvents(timeline, type) ? timeline.aggregatedEvents[eventId][type] : {}; /// 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: ]+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]?|]+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]?|]+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; }